::nsmcp::Object ::nsmcp::Object dict getdef ::nx::Object ::nx::Object __default_accessor __default_method_call_protection __object_configureparameter __resolve_method_path contains copy delete object method delete object property delete object variable destroy_on_cleanup info consts info info info lookup parameters info lookup slots info lookup syntax info lookup variables info object method args info object method body info object method callprotection info object method debug info object method definition info object method definitionhandle info object method deprecated info object method exists info object method handle info object method origin info object method parameters info object method registrationhandle info object method returns info object method submethods info object method syntax info object method type info object slots info object variables info variable definition info variable name info variable parameter move object alias object forward object method object property object variable private protected public qn require namespace require object method require private object method require protected object method require public object method serialize ::nsmcp::Object->::nx::Object ::nsmcp::QuestionService ::nsmcp::QuestionService → setup cancel choices cleanup continuationJobs continue defaultBackend describe drop event evidence forget init logQuestion owned poll provider questionPresentation recordUsage run send toolEvidence usageSummary ::nsmcp::QuestionService->::nsmcp::Object

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}
    }