Class ::nsmcp::QuestionService

::nsmcp::QuestionService[i] create ... \
           [ -backend:required backend:required ] \
           [ -backendErrors (default "") ] \
           [ -backends (default "") ] \
           [ -evidenceDirectory (default "") ] \
           [ -lifetime (default "3600") ] \
           [ -maxBytes:integer (default "2097152") ] \
           [ -maxConversations:integer (default "20") ] \
           [ -maxEvidenceBytes:integer (default "16777216") ] \
           [ -maxTurns:integer (default "20") ] \
           [ -mutex mutex ] \
           [ -questionLog:boolean (default "true") ] \
           [ -queue:required queue:required ] \
           [ -store:required store:required ]

Defined in /usr/local/ns/tcl/nsmcp/question-service.tcl

Class Relations

  • class: ::nx::Class[i]
  • superclass: ::nsmcp::Object[i]
::nx::Class create ::nsmcp::QuestionService \
     -superclass ::nsmcp::Object

Methods (to be applied on the object)

  • setup (scripted, public)

     nsmcp::QuestionService[i] setup moduleDirectory

    Configure question backends and the question service at startup. Honor enableQuestions, validate the tool registry and credentials, and establish the configured provider implementations.

    Parameters:
    moduleDirectory (required)
    Installed module directory used to resolve backend helpers.
    Returns:
    The startup setup result, or an empty result when questions are disabled.

    Testcases:
    No testcase defined.
    if {![ns_config -bool ${:section} enableQuestions false]} {
        return
    }
    if {![nsf::object::exists ::nsmcp::tools]} {
        error "Questions require the configured nsmcp HTTP endpoint"
    }
    set backend [ns_config ${:section} questionBackend codex]
    if {$backend ni {codex chat-completions}} {
        error "Unsupported question backend: $backend"
    }
    set lifetime [ns_baseunit -time [ns_config ${:section} questionLifetime 1h]]
    if {$lifetime < 1} {
        error "questionLifetime must be positive"
    }
    set maxBytes [ns_baseunit -size [ns_config ${:section} questionMaxBytes 2MiB]]
    set maxEvidence [ns_baseunit -size [ns_config ${:section} questionMaxEvidenceBytes 16MiB]]
    if {$maxBytes<65536 || $maxEvidence<1024} {
        error "questionMaxBytes requires at least 64KiB; questionMaxEvidenceBytes requires at least 1KiB"
    }
    set evidenceDirectory [ns_config ${:section} questionEvidenceDirectory [file join [ns_info home] data nsmcp-question-evidence [ns_info server]]]
    if {[file pathtype $evidenceDirectory] ne "absolute"} {
        error "questionEvidenceDirectory must be absolute"
    }
    :createBackend $backend $moduleDirectory ::nsmcp::questionBackend
    set backends [dict create $backend ::nsmcp::questionBackend]
    set errors {}
    foreach candidate {codex chat-completions} {
        if {$candidate eq $backend} {
            continue
        }
        set object ::nsmcp::questionBackend_[string map {- _} $candidate]
        if {[catch {
            :createBackend $candidate $moduleDirectory $object
        } reason]} {
            if {[nsf::object::exists $object]} {
                $object destroy
            }
            dict set errors $candidate [string range $reason 0 1023]
        } else {
            dict set backends $candidate $object
        }
    }
    :create ::nsmcp::questions -backend ::nsmcp::questionBackend  -backends $backends -backendErrors $errors -questionLog [ns_config -bool ${:section} questionLog true]  -store nsmcp:questions:[ns_info server] -queue nsmcp-questions-[ns_info server]  -lifetime $lifetime -maxBytes $maxBytes -maxEvidenceBytes $maxEvidence -evidenceDirectory $evidenceDirectory  -maxConversations [ns_config -int -min 1 ${:section} questionMaxConversations 20]  -maxTurns [ns_config -int -min 1 ${:section} questionMaxTurns 20]

Methods (to be applied on instances)

  • cancel (scripted, public)

     <instance of nsmcp::QuestionService[i]> cancel context handle

    Cancel the active turn of an owned conversation. Update queued or running state under the mutex so subsequent callbacks observe cancellation.

    Parameters:
    context (required)
    Authenticated request context carrying the principal, grants and selected server.
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    Returns:
    The cancellation/conversation result.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        set record [:owned $context $handle]
        if {[dict get $record status] eq "queued"} {
            dict set record status cancelled
            set last [lindex [dict get $record turns] end]
            dict set last status cancelled
            dict set record turns [lreplace [dict get $record turns] end end $last]
            nsv_set ${:store} $handle $record
        } elseif {[dict get $record status] eq "running"} {
            dict set record status cancelling
            nsv_set ${:store} $handle $record
        }
    } finally {
        ns_mutex unlock ${:mutex}
    }
    return [:poll $context $handle]
  • choices (scripted, public)

     <instance of nsmcp::QuestionService[i]> choices

    List configured question backend choices. Describe available providers and configured failures without contacting a provider.

    Returns:
    A dictionary containing the default backend and backend metadata.

    Testcases:
    No testcase defined.
    set providers ${:backends}
    dict set providers [:defaultBackend] ${:backend}
    set choices {}
    dict for {name object} $providers {
        dict set choices $name [dict merge [$object describe] {available true}]
    }
    dict for {name reason} ${:backendErrors} {
        if {![dict exists $choices $name]} {
            dict set choices $name [dict create available false reason $reason]
        }
    }
    return [dict create default [:defaultBackend] backends $choices]
  • continue (scripted, public)

     <instance of nsmcp::QuestionService[i]> continue context handle \
        turn [ peerAddress ]

    Resume an owned conversation from its retained continuation state. Derive the job and window from server-owned state and recheck ownership and turn validity before queuing work.

    Parameters:
    context (required)
    Authenticated request context carrying the principal, grants and selected server.
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    turn (required)
    Retained turn identifier, used to reject superseded callbacks.
    peerAddress (optional)
    Direct connection peer supplied by the authenticated HTTP adapter.
    Returns:
    The rooted JSON conversation status returned by send.

    Testcases:
    No testcase defined.
    # Derive handles from owned server state; the browser cannot substitute
    # another job or change its original time window. send rechecks the turn
    # under its queue lock, so a stale button cannot resume an older turn.
    ns_mutex lock ${:mutex}
    try {
        set record [:owned $context $handle]
        if {$turn eq "" || [dict get $record current] ne $turn} {
            error "The conversation has moved on; refresh its status before continuing"
        }
        set jobs [:continuationJobs $record]
        if {$jobs eq {}} {
            error "No retained background jobs are available to continue"
        }
        set original [dict get [lindex [dict get $record turns] end] question]
        set sourceTurn $turn
        foreach previous [dict get $record turns] {
            set found false
            foreach job $jobs {
                if {[dict exists $previous jobs $job]} {
                    set found true
                    break
                }
            }
            if {$found} {
                set original [dict get $previous question]
                set sourceTurn [dict get $previous id]
                break
            }
        }
    } finally {
        ns_mutex unlock ${:mutex}
    }
    set excerpt [string range $original 0 1023]
    if {[string length $original]>1024} {
        append excerpt " (original question excerpt)"
    }
    set question "Continue the retained analysis and finish the answer. Retrieve only these existing background jobs with log_results; keep polling them if needed. Do not start duplicate scans or reinterpret relative times: use the jobs' original fixed time windows. If a job has expired or failed, report that rather than starting a replacement.\nJob handles: [join $jobs {, }].\nOriginal request (context only): $excerpt"
    return [:send $context {Continue the previous analysis.} $handle {} {} $peerAddress $turn $question $sourceTurn]
  • describe (scripted, public)

     <instance of nsmcp::QuestionService[i]> describe

    Describe the default backend and service evidence budgets. Merge provider metadata with this service's configured memory and evidence limits.

    Returns:
    The service metadata dictionary.

    Testcases:
    No testcase defined.
    return [dict merge [${:backend} describe] [dict create maxBytes ${:maxBytes} maxEvidenceBytes ${:maxEvidenceBytes}]]
  • event (scripted, public)

     <instance of nsmcp::QuestionService[i]> event handle turn event

    Record a backend event for a specific retained turn. Serialize updates and discard events for expired or superseded turns.

    Parameters:
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    turn (required)
    Retained turn identifier, used to reject superseded callbacks.
    event (required)
    Backend event dictionary for the retained question turn.
    Returns:
    Whether the event was accepted for the active turn.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        if {![nsv_exists ${:store} $handle]} {
            return false
        }
        set record [nsv_get ${:store} $handle]
        if {[dict get $record current] ne $turn} {
            return false
        }
        set continue [expr {[dict get $record status] ne "cancelling"}]
        if {[dict get $event type] eq "check"} {
            return $continue
        }
        set last [lindex [dict get $record turns] end]
        switch -- [dict get $event type] {
            model {
                dict set last model [string range [dict get $event model] 0 255]
            }
            questionUsage {
                set raw [dict get $event usage]
                set usage [::nsmcp::questionUsageCounters $raw {
                    inputTokens inputTokens outputTokens outputTokens totalTokens totalTokens
                    cachedInputTokens cachedInputTokens cacheWriteInputTokens cacheWriteInputTokens
                    reasoningOutputTokens reasoningOutputTokens
                }]
                foreach key {scope source reason} {
                    if {[dict exists $raw $key]} {
                        dict set usage $key [string range [dict get $raw $key] 0 63]
                    }
                }
                dict set usage available [expr {[dict exists $raw available]
                    && [string is boolean -strict [dict get $raw available]] && [dict get $raw available]}]
                dict set last questionUsage $usage
            }
            usage {
                # Only the adapter-neutral allowlist reaches logs/storage.
                set raw [dict get $event usage]
                set usage [::nsmcp::questionUsageCounters $raw {
                    inputTokens inputTokens outputTokens outputTokens totalTokens totalTokens
                    cachedInputTokens cachedInputTokens uncachedInputTokens uncachedInputTokens
                    cacheWriteInputTokens cacheWriteInputTokens reasoningOutputTokens reasoningOutputTokens
                    apiRequests apiRequests requestsWithUsage requestsWithUsage
                }]
                foreach key {scope source} {
                    if {[dict exists $raw $key]} {
                        dict set usage $key [string range [dict get $raw $key] 0 63]
                    }
                }
                if {[dict exists $raw complete] && [string is boolean -strict [dict get $raw complete]]} {
                    dict set usage complete [expr {!![dict get $raw complete]}]
                }
                dict set last usage $usage
            }
            text {
                dict append last answer [dict get $event text]
            }
            job {
                set key [dict get $event jobHandle]
                if {![dict exists $last jobs $key] && [dict exists $last jobs] && [dict size [dict get $last jobs]]>=128} {
                    error "Question background job limit reached"
                }
                dict set last jobs $key $event
            }
            tool {
                dict lappend last tools [:toolEvidence $handle $event]
            }
            state {
                set state [dict get $event state]
                if {[string bytelength $state]>${:maxBytes}} {
                    error "Question continuation size limit reached; start a new conversation"
                }
                dict set record state [zlib compress [encoding convertto utf-8 $state]]
                dict set record stateEncoding zlib-tcl-v1
            }
        }
        dict set record turns [lreplace [dict get $record turns] end end $last]
        if {[string bytelength $record] > ${:maxBytes}} {
            error "Conversation size limit reached (${:maxBytes} bytes); start a new conversation"
        }
        nsv_set ${:store} $handle $record
        return $continue
    } finally {
        ns_mutex unlock ${:mutex}
    }
  • evidence (scripted, public)

     <instance of nsmcp::QuestionService[i]> evidence context handle id

    Retrieve retained tool evidence for an owned conversation. Validate the evidence identifier, confirm it belongs to a retained turn and read only the bounded private evidence file.

    Parameters:
    context (required)
    Authenticated request context carrying the principal, grants and selected server.
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    id (required)
    Private evidence identifier already referenced by an owned turn.
    Returns:
    A rooted JSON object containing the retained result text.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        set record [:owned $context $handle]
        if {![regexp {^[a-f0-9]{32}$} $id]} {
            error "Unknown evidence"
        }
        set found false
        foreach turn [dict get $record turns] {
            foreach tool [dict get $turn tools] {
                if {[dict exists $tool evidenceHandle] && [dict get $tool evidenceHandle] eq $id} {
                    set found true
                }
            }
        }
        if {!$found} {
            error "Unknown evidence"
        }
        set path [file join ${:evidenceDirectory} $handle $id.json]
        if {[file type $path] ne "file" || [file size $path]>${:maxEvidenceBytes}} {
            error "Evidence unavailable"
        }
        set channel [open $path r]
        try {
            fconfigure $channel -encoding utf-8 -translation lf
            set result [read $channel]
        } finally {
            close $channel
        }
        return [list {} object [list result string $result]]
    } finally {
        ns_mutex unlock ${:mutex}
    }
  • forget (scripted, public)

     <instance of nsmcp::QuestionService[i]> forget context handle

    Remove an owned conversation and its retained evidence. Reject active work when required and clean up private evidence with the retained conversation state.

    Parameters:
    context (required)
    Authenticated request context carrying the principal, grants and selected server.
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    Returns:
    The deletion result.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        set record [:owned $context $handle]
        if {[dict get $record status] in {queued running cancelling}} {
            error "Cancel the running question before starting a new conversation"
        }
        :drop $handle
    } finally {
        ns_mutex unlock ${:mutex}
    }
    return {{} object {status string forgotten}}
  • poll (scripted, public)

     <instance of nsmcp::QuestionService[i]> poll context handle

    Describe an owned conversation and its retained turns. Apply ownership and expiry checks before exposing bounded question, tool, evidence and usage metadata.

    Parameters:
    context (required)
    Authenticated request context carrying the principal, grants and selected server.
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    Returns:
    A rooted JSON conversation-status object.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        set record [:owned $context $handle]
    } finally {
        ns_mutex unlock ${:mutex}
    }
    set canContinue [expr {[llength [:continuationJobs $record]]>0 && [llength [dict get $record turns]]<${:maxTurns}}]
    set turns {}
    set i 0
    foreach entry [dict get $record turns] {
        set tools {}
        set j 0
        foreach call [dict get $entry tools] {
            lappend tools $j object [list name string [dict get $call name] status string [dict get $call status]  arguments string [dict get $call arguments] result string [dict get $call result] truncated boolean [dict get $call truncated]]
            set timing [::nsmcp::questionToolTiming $call]
            set fields [lindex $tools end]
            foreach {key type} {durationMs number durationSource string} {
                if {[dict exists $timing $key]} {
                    lappend fields $key $type [dict get $timing $key]
                } else {
                    lappend fields $key null {}
                }
            }
            set tools [lreplace $tools end end $fields]
            if {[dict exists $call evidenceHandle]} {
                set fields [lindex $tools end]
                lappend fields evidenceHandle string [dict get $call evidenceHandle]
                set tools [lreplace $tools end end $fields]
            }
            incr j
        }
        set jobs {}
        set k 0
        if {[dict exists $entry jobs]} {
            dict for {jobHandle job} [dict get $entry jobs] {
                set fields [list jobHandle string $jobHandle name string [dict get $job name] status string [dict get $job status]]
                if {[dict exists $job progress]} {
                    set progress {}
                    foreach key {wallMs examinedBytes sourceBytes scanBudgetBytes updatedAt filesCompleted filesProcessed filesSelected filesDiscovered filesSkipped filesScanned filesPending timeIndexSkippedBytes timeIndexValidationBytes selectedFromOffset selectedToOffset selectionProbeBytes aggregationDiskBytes aggregationDiskBudgetBytes aggregationPoolWaitCount aggregationPoolWaitMilliseconds aggregationPoolWaitLimitMilliseconds currentFileIndex currentFileNumber currentFileSize currentFileOffset currentFileSnapshotBytes} {
                        if {[dict exists $job progress $key]} {
                            set value [dict get $job progress $key]
                            lappend progress $key [expr {$value eq "" ? "null" : "number"}] $value
                        }
                    }
                    foreach key {phase offsetBasis scanStatus completionSummary aggregationBackend aggregationStatus aggregationFailurePhase aggregationFailureClass aggregationDiagnosticId} {
                        if {[dict exists $job progress $key]} {
                            lappend progress $key string [dict get $job progress $key]
                        }
                    }
                    if {[dict exists $job progress currentFileCompressed]} {
                        lappend progress currentFileCompressed boolean [dict get $job progress currentFileCompressed]
                    }
                    if {[dict exists $job progress truncatedByLimits]} {
                        lappend progress truncatedByLimits boolean [dict get $job progress truncatedByLimits]
                    }
                    lappend fields progress object $progress
                }
                lappend jobs $k object $fields
                incr k
            }
        }
        lassign [:questionPresentation $record $entry] displayed editable
        lappend turns $i object [list id string [dict get $entry id] question string $displayed editQuestion string $editable  answer string [dict get $entry answer] status string [dict get $entry status]  elapsedMs [expr {[dict exists $entry elapsedMs] ? "number" : "null"}] [:dict getdef $entry elapsedMs {}]  error string [dict get $entry error] tools array $tools jobs array $jobs  canContinue boolean [expr {$canContinue && [dict get $entry id] eq [dict get $record current]}]]
        incr i
    }
    set backendName [:defaultBackend]
    if {[dict exists $record backend]} {
        set backendName [dict get $record backend]
    }
    return [list {} object [list conversation string $handle server string [dict get $record server] backend string $backendName  status string [dict get $record status] expires number [dict get $record expires] turns array $turns]]
  • run (scripted, public)

     <instance of nsmcp::QuestionService[i]> run handle turn

    Execute a queued conversation turn. Resolve retained owner/provider state, invoke the selected backend and publish completion, failure or cancellation under the mutex.

    Parameters:
    handle (required)
    Retained job or conversation handle; ownership and expiry checks still apply.
    turn (required)
    Retained turn identifier, used to reject superseded callbacks.
    Returns:
    The worker callback result; conversation state retains the outcome.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        if {![nsv_exists ${:store} $handle]} {
            return
        }
        set record [nsv_get ${:store} $handle]
        if {[dict get $record current] ne $turn} {
            return
        }
        if {[dict get $record status] in {cancelling cancelled}} {
            set skip true
        } else {
            set skip false
            dict set record status running
            nsv_set ${:store} $handle $record
        }
        set state [dict get $record state]
        if {[dict exists $record stateEncoding]} {
            set state [encoding convertfrom utf-8 [zlib decompress $state]]
        }
        set backendName [:defaultBackend]
        if {[dict exists $record backend]} {
            set backendName [dict get $record backend]
        }
        set entry [lindex [dict get $record turns] end]
        set question [expr {[dict exists $entry backendQuestion] ? [dict get $entry backendQuestion] : [dict get $entry question]}]
    } finally {
        ns_mutex unlock ${:mutex}
    }
    # A timed-out turn may have started a job without publishing provider
    # history. Carry its handle explicitly into the next question.
    if {[dict exists $record browserTimezone]} {
        append question "\nBrowser timezone context: [dict get $record browserTimezone]. Use this timezone for unspecified time windows and displayed times, unless the question explicitly requests another timezone."
    }
    set pendingJobs {}
    foreach previous [dict get $record turns] {
        if {![dict exists $previous jobs]} {
            continue
        }
        dict for {jobHandle job} [dict get $previous jobs] {
            dict set job needsRetrieval [expr {[dict get $previous status] in {failed cancelled}
                || [dict get $job status] in {queued running}}]
            dict set pendingJobs $jobHandle $job
        }
    }
    dict for {jobHandle job} $pendingJobs {
        if {[dict get $job needsRetrieval]} {
            append question "\nExisting background jobHandle $jobHandle ([dict get $job name]); retrieve it with log_results rather than starting it again."
        }
    }
    set error {}
    set status cancelled
    if {!$skip} {
        if {[catch {
            [:provider $backendName] run $question $state [list [self] event $handle $turn]
        } status]} {
            set error $status
            set status failed
        }
    }
    ns_mutex lock ${:mutex}
    try {
        if {![nsv_exists ${:store} $handle]} {
            return
        }
        set record [nsv_get ${:store} $handle]
        if {[dict get $record current] ne $turn} {
            return
        }
        if {[dict get $record status] eq "cancelling"} {
            set status cancelled
            set error {}
        }
        dict set record status $status
        set last [lindex [dict get $record turns] end]
        dict set last status $status
        dict set last error $error
        if {[dict exists $last submittedMs]} {
            dict set last elapsedMs [expr {max(0,[clock milliseconds]-[dict get $last submittedMs])}]
        }
        :logQuestion finished $record $last
        dict set record turns [lreplace [dict get $record turns] end end $last]
        nsv_set ${:store} $handle $record
    } finally {
        ns_mutex unlock ${:mutex}
    }
  • send (scripted, public)

     <instance of nsmcp::QuestionService[i]> send context question \
        [ handle ] [ backendName ] [ browserTimezone ] [ peerAddress ] \
        [ expectedTurn ] [ backendQuestion ] [ continuedFrom ]

    Queue a question or follow-up with optional browser timezone context. Retain authenticated ownership, backend selection and turn guards when queuing asynchronous provider work. Keep the displayed question separate from server-generated continuation instructions.

    Parameters:
    context (required)
    Authenticated request context carrying the principal, grants and selected server.
    question (required)
    Question text supplied to the selected provider.
    handle (optional)
    Existing conversation handle, or empty to create a new conversation.
    backendName (optional)
    Backend selection for a new conversation; empty retains the configured default.
    browserTimezone (optional)
    Validated browser timezone used unless the question overrides it; empty retains prior context or UTC.
    peerAddress (optional)
    Direct connection peer supplied by the authenticated HTTP adapter.
    expectedTurn (optional)
    Optional internal guard against continuing a superseded turn.
    backendQuestion (optional)
    Optional server-generated backend task, separate from the displayed question.
    continuedFrom (optional)
    Owned source-turn identifier when editing the original request.
    Returns:
    Rooted JSON triples describing the queued conversation.

    Testcases:
    No testcase defined.
    if {$browserTimezone ne "" && (![regexp {^[A-Za-z0-9_+:/-]{1,128}$} $browserTimezone]
        || [catch {
        clock format 0 -timezone $browserTimezone
    }])} {
        error "Invalid browser timezone"
    }
    if {$peerAddress ne "" && ![ns_ip valid $peerAddress]} {
        error "Invalid question peer address"
    }
    set question [string trim $question]
    if {$question eq "" || [string bytelength $question] > 16384} {
        error "Supply a question up to 16 KiB"
    }
    if {$backendQuestion ne {} && ([string bytelength $backendQuestion]>16384 || $expectedTurn eq {} || $continuedFrom eq {})} {
        error "Invalid internal continuation task"
    }
    ns_mutex lock ${:mutex}
    try {
        if {$handle eq ""} {
            :cleanup
            foreach key [nsv_array names ${:store}] {
                set old [nsv_get ${:store} $key]
                if {[dict get $old expires] <= [clock seconds] && [dict get $old status] ni {queued running cancelling}} {
                    :drop $key
                }
            }
            if {[llength [nsv_array names ${:store}]] >= ${:maxConversations}} {
                error "Conversation limit reached; remove an old conversation"
            }
            if {$backendName eq ""} {
                set backendName [:defaultBackend]
            }
            :provider $backendName
            set handle [ns_crypto::randombytes -encoding hex 32]
            set record [dict create principal [$context principal] server [$context server] backend $backendName status idle state {} turns {}]
        } else {
            set record [:owned $context $handle]
            if {![dict exists $record backend]} {
                dict set record backend [:defaultBackend]
            }
            if {$backendName ne "" && $backendName ne [dict get $record backend]} {
                error "Changing backend requires a new conversation"
            }
        }
        if {$backendQuestion ne {}} {
            set ownedSource false
            foreach entry [dict get $record turns] {
                if {[dict get $entry id] eq $continuedFrom} {
                    set ownedSource true
                    break
                }
            }
            if {!$ownedSource} {
                error "Continuation source is not in this conversation"
            }
        }
        if {$expectedTurn ne "" && [dict get $record current] ne $expectedTurn} {
            error "The conversation has moved on; refresh its status before continuing"
        }
        if {[dict get $record status] in {queued running cancelling}} {
            error "A question is already running in this conversation"
        }
        if {[llength [dict get $record turns]] >= ${:maxTurns}} {
            error "Conversation turn limit reached; start a new conversation"
        }
        if {$browserTimezone ne ""} {
            dict set record browserTimezone $browserTimezone
        }
        set turn [ns_crypto::randombytes -encoding hex 16]
        dict set record current $turn
        dict set record status queued
        dict set record expires [expr {[clock seconds]+int(${:lifetime})}]
        set description [[:provider [dict get $record backend]] describe]
        set model {}
        if {[dict exists $description model]} {
            set model [dict get $description model]
        }
        set last [dict create id $turn conversation $handle question $question answer {} status queued tools {} error {}  peerAddress $peerAddress peerSource [expr {$peerAddress eq "" ? "unavailable" : "direct"}]  submittedAt [clock seconds] submittedMs [clock milliseconds] requestedModel $model]
        if {$backendQuestion ne {}} {
            dict set last backendQuestion $backendQuestion
            dict set last continuedFrom $continuedFrom
        }
        if {[dict exists $description backend] && [dict get $description backend] eq "codex"} {
            dict set last questionUsage {source codex scope turn available false reason no-provider-usage}
        }
        dict lappend record turns $last
        :logQuestion submitted $record $last
        nsv_set ${:store} $handle $record
        try {
            ns_job queue -detached ${:queue} [list [self] run $handle $turn]
        } on error {message options} {
            dict set record status failed
            set last [lindex [dict get $record turns] end]
            dict set last status failed
            dict set last error "Unable to queue the question"
            dict set record turns [lreplace [dict get $record turns] end end $last]
            nsv_set ${:store} $handle $record
            :logQuestion finished $record $last
            return -options $options $message
        }
    } finally {
        ns_mutex unlock ${:mutex}
    }
    return [:poll $context $handle]
  • usageSummary (scripted, public)

     <instance of nsmcp::QuestionService[i]> usageSummary

    Summarize retained finished-question token records for this server. Keep unknown usage explicit and exclude cumulative provider snapshots from summed turn usage. Apply the retained UTC-day and read bounds.

    Returns:
    A dictionary of UTC-day, backend/model and principal rollups, complete/partial counters, missing counts and read limits.

    Testcases:
    No testcase defined.
    ns_mutex lock ${:mutex}
    try {
        set result [::nsmcp::usageRead [file join ${:evidenceDirectory} token-usage.jsonl] [ns_info server]]
        dict set result loggingEnabled ${:questionLog}
        set failures 0
        if {[nsv_exists ${:store}:usage writeFailures]} {
            set failures [nsv_get ${:store}:usage writeFailures]
        }
        dict set result processWriteFailures $failures
        return $result
    } finally {
        ns_mutex unlock ${:mutex}
    }