Class ::nsmcp::QuestionService (public)

 ::nx::Class ::nsmcp::QuestionService[i]

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

Testcases:
No testcase defined.
Source code:
    :object variable section ns/server/[ns_info server]/module/nsmcp

    :property backend:required
    :property {backends {}}
    :property {backendErrors {}}
    :property store:required
    :property queue:required
    :property {jobStore {}}
    :property {lifetime 3600}
    :property {maxConversations:integer 20}
    :property {maxTurns:integer 20}
    :property {maxBytes:integer 2097152}
    :property {maxEvidenceBytes:integer 16777216}
    :property {evidenceDirectory {}}
    :property {questionLog:boolean true}
    :variable mutex

    :method init {} {
        if {${:maxBytes}<1 || ${:maxEvidenceBytes}<1} {
            error "Question storage limits must be positive"
        }
        if {${:evidenceDirectory} eq ""} {
            set channel [file tempfile path]
            close $channel
            file delete $path
            set :evidenceDirectory ${path}.questions
        }
        set :evidenceDirectory [file normalize ${:evidenceDirectory}]
        if {![file exists ${:evidenceDirectory}]} {
            file mkdir ${:evidenceDirectory}
            file attributes ${:evidenceDirectory} -permissions 0700
        }
        if {[file type ${:evidenceDirectory}] ne "directory" || ([file attributes ${:evidenceDirectory} -permissions]&0077)!=0} {
            error "Question evidence directory must be private (0700)"
        }
        :cleanup
        set :mutex [ns_mutex create "nsmcp:questions:[ns_info server]:[self]"]
        if {${:queue} ni [ns_job queues]} {
            ns_job create ${:queue} 1
        }
    }

    :method logQuestion {event record last} {
        if {${:questionLog}} {
            ns_log notice "nsmcp-question [::nsmcp::questionLogJson $event $record $last]"
            if {$event eq "finished"} {
                if {[catch {
                    :recordUsage $record $last
                } message]} {
                    nsv_incr ${:store}:usage writeFailures
                    ns_log error "nsmcp: token usage journal write failed: $message"
                }
            }
        }
    }

    :method recordUsage {record turn} {
        # Called under the existing question-service mutex, including rotation.
        set path [file join ${:evidenceDirectory} token-usage.jsonl]
        set line [::nsmcp::usageRecordJson [::nsmcp::usageRecord $record $turn [clock seconds]]]
        if {[string bytelength $line] > 16384} {
            error "token usage record exceeds limit"
        }
        if {[file exists $path] && [file size $path]+[string bytelength $line]+1 > 4194304} {
            file rename -force $path ${path}.1
        }
        set channel [open $path {WRONLY CREAT APPEND} 0600]
        try {
            fconfigure $channel -encoding utf-8 -translation lf
            puts $channel $line
        } finally {
            close $channel
        }
    }

    :public method 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.
        #
        # @return A dictionary of UTC-day, backend/model and principal rollups,
        #         complete/partial counters, missing counts and read limits.
        #

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

    :method owned {context handle} {
        if {![regexp {^[a-f0-9]{64}$} $handle] || ![nsv_exists ${:store} $handle]} {
            return -code error -errorcode {NSMCP QUESTION CONVERSATION_UNAVAILABLE} "Unknown or expired conversation"
        }
        set record [nsv_get ${:store} $handle]
        if {[dict get $record principal] ne [$context principal] || [dict get $record server] ne [$context server]} {
            return -code error -errorcode {NSMCP QUESTION CONVERSATION_UNAVAILABLE} "Unknown or expired conversation"
        }
        if {[dict get $record expires] <= [clock seconds] && [dict get $record status] ni {queued running cancelling}} {
            :drop $handle
            return -code error -errorcode {NSMCP QUESTION CONVERSATION_UNAVAILABLE} "Unknown or expired conversation"
        }
        return $record
    }

    :public method describe {} {
        #
        # Describe the default backend and service evidence budgets.
        #
        # Merge provider metadata with this service's configured memory and evidence
        # limits.
        #
        # @return The service metadata dictionary.
        #

        return [dict merge [${:backend} describe] [dict create maxBytes ${:maxBytes} maxEvidenceBytes ${:maxEvidenceBytes}]]
    }

    # Evidence is separate from the bounded conversation/continuation record.
    # Callers hold the service mutex; public retrieval verifies conversation
    # ownership before accepting an opaque evidence identifier.
    :method cleanup {} {
        foreach path [glob -nocomplain -directory ${:evidenceDirectory} *] {
            set handle [file tail $path]
            if {![regexp {^[a-f0-9]{64}$} $handle]} {
                continue
            }
            if {![nsv_exists ${:store} $handle]} {
                file delete -force $path
                continue
            }
            set record [nsv_get ${:store} $handle]
            if {[dict get $record expires]<=[clock seconds] && [dict get $record status] ni {queued running cancelling}} {
                :drop $handle
            }
        }
    }

    :method drop {handle} {
        file delete -force [file join ${:evidenceDirectory} $handle]
        nsv_unset -nocomplain ${:store} $handle
    }

    :method toolEvidence {handle event} {
        set timing [::nsmcp::questionToolTiming $event]
        foreach key {durationMs durationSource} {
            if {[dict exists $event $key]} {
                dict unset event $key
            }
        }
        set event [dict merge $event $timing]
        set result [dict get $event result]
        if {[dict exists $event evidenceResult]} {
            set result [dict get $event evidenceResult]
            dict unset event evidenceResult
            # The full backend event is available for evidence storage. This says
            # nothing about provider-side model-history truncation.
            dict set event truncated false
        }
        set directory [file join ${:evidenceDirectory} $handle]
        set used 0
        foreach path [glob -nocomplain -directory $directory *.json] {
            incr used [file size $path]
        }
        set bytes [string length [encoding convertto utf-8 $result]]
        dict set event result {}
        dict set event evidenceHandle {}
        if {$used+$bytes>${:maxEvidenceBytes}} {
            dict set event result {Evidence omitted: conversation evidence storage limit reached.}
            dict set event truncated true
            return $event
        }
        set temporary {}
        set channel {}
        if {[catch {
            if {![file exists $directory]} {
                file mkdir $directory
                file attributes $directory -permissions 0700
            }
            set id [ns_crypto::randombytes -encoding hex 16]
            set channel [file tempfile temporary [file join $directory pending-]]
            file attributes $temporary -permissions 0600
            fconfigure $channel -encoding utf-8 -translation lf
            puts -nonewline $channel $result
            close $channel
            set channel {}
            file rename $temporary [file join $directory $id.json]
            dict set event evidenceHandle $id
            # Tiny results remain inline for older admin clients and cheap polling.
            if {$bytes<=4096} {
                dict set event result $result
            }
        } reason]} {
            if {$channel ne ""} {
                catch {
                    close $channel
                }
            }
            if {$temporary ne ""} {
                catch {
                    file delete $temporary
                }
            }
            ns_log warning "nsmcp: question evidence could not be retained: $reason"
            dict set event result {Evidence unavailable: storage failed. The model may still have used this result.}
            dict set event truncated true
        }
        return $event
    }

    :public method 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.
        #
        # @param context Authenticated request context carrying the principal,
        #        grants and selected server.
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        # @param id Private evidence identifier already referenced by an owned turn.
        #
        # @return A rooted JSON object containing the retained result text.
        #

        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
                        set evidenceTool $tool
                    }
                }
            }
            if {!$found} {
                error "Unknown evidence"
            }
            return [:readEvidence $handle $id $evidenceTool]
        } finally {
            ns_mutex unlock ${:mutex}
        }
    }

    :method readEvidence {handle id evidenceTool} {
        set path [file join ${:evidenceDirectory} $handle $id.json]
        if {[file type $path] ne "file" || [file size $path]>${:maxEvidenceBytes}} {
            error "Evidence unavailable"
        }
        # Decode the complete byte sequence at once.  Channel decoding can
        # split supplementary Unicode characters at an input-buffer boundary.
        set channel [open $path rb]
        try {
            set wireResult [read $channel]
        } finally {
            close $channel
        }
        set bytes [string length $wireResult]
        set digest [ns_crypto::md string -digest sha256 -binary -encoding hex -- $wireResult]
        set result [encoding convertfrom utf-8 $wireResult]
        if {([dict exists $evidenceTool resultBytes] && [dict get $evidenceTool resultBytes]!=$bytes)
            || ([dict exists $evidenceTool resultSha256] && [dict get $evidenceTool resultSha256] ne $digest)} {
            error "Evidence integrity check failed: retained tool result is incomplete or changed"
        }
        return [list {} object [list result string $result resultBytes number $bytes resultSha256 string $digest]]
    }

    :method retainedSummary {record conversation jobHandle} {
        if {![regexp {^[0-9a-f]{64}$} $jobHandle]} {
            return {}
        }
        foreach turn [lreverse [dict get $record turns]] {
            foreach tool [lreverse [dict get $turn tools]] {
                if {[dict get $tool name] ni {log_error_summary access_log_summary log_results}
                    || [dict get $tool truncated]
                    || ![dict exists $tool evidenceHandle]
                    || [dict get $tool evidenceHandle] eq {}} {
                    continue
                }
                if {[dict get $tool name] eq "log_results"
                    && ([catch {ns_json parse [dict get $tool arguments]} args]
                        || ![dict exists $args jobHandle] || [dict get $args jobHandle] ne $jobHandle)} {
                    continue
                }
                try {
                    set evidence [:readEvidence $conversation [dict get $tool evidenceHandle] $tool]
                    set json [lindex $evidence 2 2]
                    set payload [dict get [ns_json parse $json] structuredContent]
                    if {[dict get $payload jobHandle] ne $jobHandle
                        || [dict get $payload status] ne "complete"
                        || [dict get $payload server] ne [dict get $record server]
                        || ![dict exists $payload result]} {
                        continue
                    }
                    # Older system-log summaries predate the explicit marker.
                    if {![:dict getdef $payload retainedSummary false]
                        && ![dict exists $payload result severityTotals]} {
                        continue
                    }
                    set triples [ns_json parse -output triples -- $json]
                    set members [ns_json triples getvalue -output triples -path structuredContent -- $triples]
                    return [list {} object $members]
                } on error {} {
                    continue
                }
            }
        }
        return {}
    }

    :method defaultBackend {} {
        return [dict get [${:backend} describe] backend]
    }

    :method provider {name} {
        if {[dict exists ${:backends} $name]} {
            return [dict get ${:backends} $name]
        }
        if {$name eq [:defaultBackend]} {
            return ${:backend}
        }
        error "Question backend is not available"
    }

    # Choices are configuration metadata only; do not contact either provider.
    :public method choices {} {
        #
        # List configured question backend choices.
        #
        # Describe available providers and configured failures without contacting a
        # provider.
        #
        # @return A dictionary containing the default backend and backend metadata.
        #

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

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

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

    :method continuationJobs {record} {
        if {[dict get $record status] ni {failed cancelled complete} || [dict get $record turns] eq {}} {
            return {}
        }
        set last {}
        # A failed continuation can stop before its first job event. Keep the
        # most recent retained batch available in that case, without combining
        # unbounded batches from every historical question.
        foreach previous [lreverse [dict get $record turns]] {
            if {[dict exists $previous jobs] && [dict size [dict get $previous jobs]]>0} {
                set last $previous
                break
            }
        }
        if {$last eq {}} {
            return {}
        }
        set handles {}
        dict for {handle job} [dict get $last jobs] {
            if {![regexp {^[0-9a-f]{64}$} $handle] || [dict get $job status] ni {queued running complete}} {
                continue
            }
            if {[dict get $job status] eq "complete"
                && ([dict get $last status] eq "complete"
                    || [:retainedSummary $record [dict get $last conversation] $handle] ne {})} {
                continue
            }
            lappend handles $handle
        }
        return $handles
    }

    :method originTurn {record entry} {
        # Follow owned continuation links so later stages retain the actual
        # original request rather than the generic Continue presentation label.
        set seen {}
        while {[dict exists $entry continuedFrom]} {
            set id [dict get $entry continuedFrom]
            if {$id in $seen} {break}
            lappend seen $id
            set found false
            foreach previous [dict get $record turns] {
                if {[dict get $previous id] eq $id} {
                    set entry $previous
                    set found true
                    break
                }
            }
            if {!$found} {break}
        }
        return $entry
    }

    :method observeJob {record handle job} {
        # Only caller-owned conversation handles reach this method. Read one
        # atomic retained snapshot, never invoke tools, load evidence or run SQL.
        set store [expr {[info exists :jobStore] ? ${:jobStore} : {}}]
        # Definition reload preserves existing service objects. Resolve the
        # current log-service store lazily when the new property is unset.
        if {$store eq {} && [nsf::object::exists ::nsmcp::logService]} {
            set store [::nsmcp::logService cget -store]
        }
        if {$store eq {}} {return $job}
        dict set job live true
        if {[catch {nsv_get $store $handle} snapshot]
            || ![dict exists $snapshot server] || [dict get $snapshot server] ne [dict get $record server]
            || ![dict exists $snapshot expires] || [dict get $snapshot expires]<=[clock seconds]} {
            dict set job status expired
            return $job
        }
        if {![dict exists $snapshot status] || [dict get $snapshot status] ni {queued running complete failed}} {
            dict set job live false
            return $job
        }
        ::nsmcp::logJobTiming snapshot {}
        dict set job status [dict get $snapshot status]
        foreach key {progress processingTime} {
            if {[dict exists $snapshot $key]} {
                dict set job $key [ns_json parse [ns_json value -- [list {} object [dict get $snapshot $key]]]]
            }
        }
        set occupancy [::nsmcp::logJobQueueFields $snapshot]
        if {$occupancy ne {}} {
            dict set job queueOccupancy [ns_json parse [ns_json value -- [list {} object $occupancy]]]
        }
        return $job
    }

    :method jobWatch {record} {
        set handles [:continuationJobs $record]
        if {$handles eq {} && [dict get $record status] in {queued running cancelling}} {
            set entry [lindex [dict get $record turns] end]
            if {[dict exists $entry jobs]} {set handles [dict keys [dict get $entry jobs]]}
        }
        set observed {}
        set waiting false
        set ready [expr {$handles ne {} && [dict get $record status] eq "complete"}]
        set unseen false
        foreach handle $handles {
            set job {}
            foreach entry [lreverse [dict get $record turns]] {
                if {[dict exists $entry jobs $handle]} {set job [dict get $entry jobs $handle]; break}
            }
            set job [:observeJob $record $handle $job]
            dict set observed $handle $job
            if {![:dict getdef $job live false]} {set ready false; continue}
            set status [dict get $job status]
            if {$status in {queued running}} {set waiting true}
            if {$status ne "complete"} {set ready false}
            if {![dict exists $record automaticJobs $handle]} {set unseen true}
        }
        return [dict create observed $observed waiting [expr {$waiting && [dict get $record status] eq "complete"}]  ready [expr {$ready && $unseen}]]
    }

    :public method continue {context handle turn {peerAddress {}} {automatic false}} {
        #
        # 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.
        #
        # @param context Authenticated request context carrying the principal,
        #        grants and selected server.
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        # @param turn Retained turn identifier, used to reject superseded callbacks.
        # @param peerAddress Direct connection peer supplied by the authenticated
        #        HTTP adapter.
        # @param automatic Whether this is a server-guarded automatic continuation.
        #        Automatic and manual continuations have distinct public labels.
        #
        # @return The rooted JSON conversation status returned by send.
        #

        # 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"
            }
            if {![string is boolean -strict $automatic]} {error "Invalid automatic continuation flag"}
            if {$automatic} {
                if {![dict get [:jobWatch $record] ready]} {
                    error "Retained results are not ready for automatic continuation"
                }
                # Claim each completed batch once under the question mutex. Two
                # tabs cannot queue the same automatic model round. send also
                # checks the expected turn, preventing a stale callback race.
                foreach job $jobs {dict set record automaticJobs $job 1}
                if {[string bytelength $record]>${:maxBytes}} {error "Conversation size limit reached"}
                nsv_set ${:store} $handle $record
            }
            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 source [:originTurn $record $previous]
                    set original [dict get $source question]
                    set sourceTurn [dict get $source 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 original request. Retrieve these existing background jobs first with log_results. If they remain pending, report their retained handles promptly rather than repeatedly polling. Once they complete, perform any remaining analysis needed for the original request. A totals-only result is context, not a completed origins analysis. If origins were requested and not yet returned, start at most one bounded evidence job using the same exact status filters and timezone and explicit from/to within the original frozen window. Reuse an existing origin-evidence job instead of duplicating it. Keep full-period totals separate from evidence-window origin counts and qualify the evidence scope. Do not restart completed work, start duplicate scans or reinterpret relative times. If an existing job expired or failed, report that rather than silently replacing it.\nJob handles: [join $jobs {, }].\nOriginal request to finish: $excerpt"
        set label [expr {$automatic ? "Auto-continue the previous analysis." : "Continue the previous analysis."}]
        return [:send $context $label $handle {} {} $peerAddress $turn $question $sourceTurn]
    }

    :public method event {handle turn event} {
        #
        # Record a backend event for a specific retained turn.
        #
        # Serialize updates and discard events for expired or superseded turns.
        #
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        # @param turn Retained turn identifier, used to reject superseded callbacks.
        # @param event Backend event dictionary for the retained question turn.
        #
        # @return Whether the event was accepted for the active turn.
        #

        ns_mutex lock ${:mutex}
        try {
            if {![nsv_exists ${:store} $handle] || [dict get [nsv_get ${:store} $handle] current] ne $turn} {
                if {[dict get $event type] eq "retainedSummary"} {
                    return -code error -errorcode {NSMCP QUESTION CANCELLED} "Question is no longer active"
                }
                return false
            }
            set record [nsv_get ${:store} $handle]
            set continue [expr {[dict get $record status] ne "cancelling"}]
            if {[dict get $event type] eq "check"} {
                return $continue
            }
            if {[dict get $event type] eq "retainedSummary"} {
                if {!$continue} {
                    return -code error -errorcode {NSMCP QUESTION CANCELLED} "Question cancelled"
                }
                return [:retainedSummary $record $handle [dict get $event jobHandle]]
            }
            set last [lindex [dict get $record turns] end]
            switch -- [dict get $event type] {
                backendDiagnostic {
                    set raw [dict get $event diagnostic]
                    set safe {}
                    foreach key {stage lastEvent lastItemType lastCompletedItemType} {
                        if {[dict exists $raw $key]
                            && [regexp {^[A-Za-z][A-Za-z0-9/_-]{0,95}$} [dict get $raw $key]]} {
                            dict set safe $key [dict get $raw $key]
                        }
                    }
                    foreach key {elapsedMs stderrBytes activeToolCount oldestActiveToolMs} {
                        if {[dict exists $raw $key] && [string is wideinteger -strict [dict get $raw $key]]
                            && [dict get $raw $key]>=0} {
                            dict set safe $key [dict get $raw $key]
                        }
                    }
                    set active {}
                    if {[dict exists $raw activeTools]} {
                        foreach name [lrange [dict get $raw activeTools] 0 31] {
                            if {[regexp {^[A-Za-z][A-Za-z0-9_-]{0,127}$} $name]} {
                                lappend active $name
                            }
                        }
                    }
                    dict set safe activeTools $active
                    set timings {}
                    if {[dict exists $raw rpcMilliseconds]} {
                        dict for {method duration} [dict get $raw rpcMilliseconds] {
                            if {$method in {initialize account/read config/read mcpServerStatus/list thread/start thread/resume turn/start}
                                && [string is wideinteger -strict $duration] && $duration>=0} {
                                dict set timings $method $duration
                            }
                        }
                    }
                    dict set safe rpcMilliseconds $timings
                    dict set safe turnSubmitted [expr {[dict exists $raw turnSubmitted]
                        && [dict get $raw turnSubmitted] eq "true"}]
                    if {[dict exists $raw initializationCapture] && ![catch {
                        ns_json parse [ns_json value -- [list {} object [::nsmcp::codexProcessTriples [dict get $raw initializationCapture]]]]
                    } capture]} {
                        dict set safe initializationCapture $capture
                    }
                    dict set last backendDiagnostic $safe
                }
                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}
        }
    }

    :public method 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.
        #
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        # @param turn Retained turn identifier, used to reject superseded callbacks.
        #
        # @return The worker callback result; conversation state retains the
        #         outcome.
        #

        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 $job status] in {queued running}
                    || ([dict get $job status] eq "complete" && [dict get $previous status] in {failed cancelled}
                        && [:retainedSummary $record $handle $jobHandle] eq {})}]
                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}
        }
    }

    :public method cancel {context handle} {
        #
        # Cancel the active turn of an owned conversation.
        #
        # Update queued or running state under the mutex so subsequent callbacks
        # observe cancellation.
        #
        # @param context Authenticated request context carrying the principal,
        #        grants and selected server.
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        #
        # @return The cancellation/conversation result.
        #

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

    :public method 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.
        #
        # @param context Authenticated request context carrying the principal,
        #        grants and selected server.
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        #
        # @return The deletion result.
        #

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

    :method questionPresentation {record entry} {
        set display [dict get $entry question]
        set editable $display
        if {[dict exists $entry continuedFrom]} {
            set editable [dict get [:originTurn $record $entry] question]
        } elseif {[string first {Continue the retained analysis and finish the answer. Retrieve only these existing background jobs with log_results;} $display]==0
            && [regexp {\nJob handles: ([0-9a-f, ]+)\.\nOriginal request \(context only\): } $display unused ids]} {
            # Presentation compatibility for continuations made by older drafts.
            # Only recognize handles actually retained in this owned record.
            set handles [split [string map {{, } { }} $ids] { }]
            set foundSource {}
            set valid true
            foreach handle $handles {
                set found false
                if {![regexp {^[0-9a-f]{64}$} $handle]} {
                    set valid false
                    break
                }
                foreach source [dict get $record turns] {
                    if {[dict exists $source jobs $handle]} {
                        set found true
                        if {$foundSource eq {}} {
                            set foundSource $source
                        }
                        break
                    }
                }
                if {!$found} {
                    set valid false
                    break
                }
            }
            if {$valid && $foundSource ne {}} {
                set display {Continue the previous analysis.}
                set editable [dict get $foundSource question]
            }
        }
        return [list $display $editable]
    }

    :public method 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.
        #
        # @param context Authenticated request context carrying the principal,
        #        grants and selected server.
        # @param handle Retained job or conversation handle; ownership and expiry
        #        checks still apply.
        #
        # @return A rooted JSON conversation-status object.
        #

        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 watch [:jobWatch $record]
        if {!$canContinue} {
            dict set watch waiting false
            dict set watch ready false
        }
        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 {}
                    }
                }
                foreach {key type} {displayOmitted boolean resultBytes number resultSha256 string resultSource string modelResultStatus string} {
                    if {[dict exists $call $key]} {lappend fields $key $type [dict get $call $key]}
                }
                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] {
                    if {[dict exists $watch observed $jobHandle]} {
                        set job [dict get $watch observed $jobHandle]
                    }
                    set fields [list jobHandle string $jobHandle name string [dict get $job name] status string [dict get $job status] live boolean [:dict getdef $job live false]]
                    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
                    }
                    if {[dict exists $job processingTime]} {
                        set timing {}
                        foreach key {wallMs workerMs initialQueueMs queueWaitMs executionMs betweenSlicesMs} {
                            if {[dict exists $job processingTime $key]} {
                                set value [dict get $job processingTime $key]
                                if {[string is wideinteger -strict $value] && $value>=0 && $value<=9007199254740991} {
                                    lappend timing $key number $value
                                }
                            }
                        }
                        lappend fields processingTime object $timing
                    }
                    if {[dict exists $job queueOccupancy]} {
                        set occupancy {}
                        foreach stage {submission start} {
                            if {![dict exists $job queueOccupancy $stage]} {continue}
                            set snapshot {}
                            foreach key {observedMs maxThreads running scheduled jobs globalMaxThreads globalThreads globalIdle} {
                                if {[dict exists $job queueOccupancy $stage $key]} {
                                    set value [dict get $job queueOccupancy $stage $key]
                                    if {[string is wideinteger -strict $value] && $value>=0 && $value<=9007199254740991} {
                                        lappend snapshot $key number $value
                                    }
                                }
                            }
                            lappend occupancy $stage object $snapshot
                        }
                        lappend fields queueOccupancy object $occupancy
                    }
                    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] watchJobs boolean [dict get $watch waiting]  autoContinueReady boolean [dict get $watch ready] expires number [dict get $record expires] turns array $turns]]
    }

    # Startup configuration belongs to the class object: the service instance
    # and its adapters do not exist yet. Backend construction is private; only
    # trusted configuration may supply endpoints, credentials and commands.
    :object method createBackend {backend moduleDirectory object} {
        set tokenFile [ns_config ${:section} questionMcpTokenFile {}]
        if {$tokenFile eq "" || [file pathtype $tokenFile] ne "absolute" || ![file isfile $tokenFile] || ![file readable $tokenFile]} {
            error "questionMcpTokenFile must name a readable absolute MCP credential file"
        }
        set timeout [ns_baseunit -time [ns_config ${:section} questionTimeout 180s]]
        set maxBytes [ns_baseunit -size [ns_config ${:section} questionMaxBytes 2MiB]]
        set maxResult [ns_baseunit -size [ns_config ${:section} questionMaxResultBytes 128KiB]]
        if {$timeout < 1 || $timeout > 3600 || $maxResult < 1024} {
            error "Questions require a 1..3600s timeout, result limit of at least 1KiB"
        }
        set field [expr {$backend eq "codex" ? "questionCodexModel" : "questionApiModel"}]
        set model [ns_config ${:section} $field {}]
        if {$model eq "" && $backend eq [ns_config ${:section} questionBackend codex]} {
            set model [ns_config ${:section} questionModel {}]
        }
        switch -- $backend {
            codex {
                set outputTokens [ns_config -int ${:section} questionCodexToolOutputTokens 65536]
                if {$outputTokens<1024 || $outputTokens>262144} {
                    error "questionCodexToolOutputTokens must be between 1024 and 262144"
                }
                set initializeTimeout [ns_baseunit -time [ns_config ${:section} questionCodexInitializeTimeout 30s]]
                if {$initializeTimeout < 1 || $initializeTimeout > 3600} {
                    error "questionCodexInitializeTimeout must be between 1s and 3600s"
                }
                set directory [ns_config ${:section} questionDirectory [file join [ns_info home] data nsmcp-questions]]
                file mkdir $directory
                set url [ns_config ${:section} questionMcpUrl {}]
                if {![regexp {^https?://[^/]+/} $url]} {
                    error "questionMcpUrl must name an absolute HTTP(S) MCP endpoint"
                }
                set host [ns_config ${:section} questionMcpHost {}]
                if {$host ne "" && ![regexp {^[A-Za-z0-9.\[\]:_-]+$} $host]} {
                    error "questionMcpHost must be an HTTP authority without whitespace or a path"
                }
                set command [ns_config ${:section} questionCodexCommand codex]
                set bridgeHost [ns_config ${:section} questionCodexBridgeHost {}]
                set bridgePort [ns_config -int -min 1 -max 65535 ${:section} questionCodexBridgePort 8766]
                set bridgeSecretFile [ns_config ${:section} questionCodexBridgeSecretFile {}]
                set bridgeDirectory [ns_config ${:section} questionCodexBridgeDirectory /work]
                if {$bridgeHost eq ""} {
                    if {[auto_execok $command] eq ""} {
                        error "questionCodexCommand is not executable: $command"
                    }
                } else {
                    if {![regexp {^[A-Za-z0-9_.:-]+$} $bridgeHost]} {
                        error "Invalid questionCodexBridgeHost"
                    }
                    if {[file pathtype $bridgeSecretFile] ne "absolute" || ![file isfile $bridgeSecretFile] || ![file readable $bridgeSecretFile]} {
                        error "questionCodexBridgeSecretFile requires a readable absolute file"
                    }
                    if {[file pathtype $bridgeDirectory] ne "absolute"} {
                        error "questionCodexBridgeDirectory must be absolute"
                    }
                }
                set launcher [file join $moduleDirectory scripts codex-questions.sh]
                ::nsmcp::CodexQuestionBackend create $object  -command $command -launcher $launcher  -bridgeHost $bridgeHost -bridgePort $bridgePort -bridgeSecretFile $bridgeSecretFile -bridgeDirectory $bridgeDirectory  -tokenFile $tokenFile -url $url -host $host -directory $directory  -model $model -timeout $timeout -initializeTimeout $initializeTimeout -toolOutputTokens $outputTokens -maxResultBytes $maxResult
            }
            chat-completions {
                set apiKeyFile [ns_config ${:section} questionApiKeyFile {}]
                if {[file pathtype $apiKeyFile] ne "absolute" || ![file isfile $apiKeyFile] || ![file readable $apiKeyFile]} {
                    error "questionApiKeyFile must name a readable absolute API key file"
                }
                if {$model eq ""} {
                    error "questionModel is required for chat-completions"
                }
                set url [ns_config ${:section} questionApiUrl {}]
                if {[catch {
                    ns_parseurl $url
                } parsed] || ![dict exists $parsed proto] || ![dict exists $parsed host]
                    || [dict exists $parsed userinfo] || [dict exists $parsed query] || [dict exists $parsed fragment]
                    || ([dict get $parsed proto] ne "https"
                        && !([dict get $parsed proto] eq "http" && [dict get $parsed host] in {127.0.0.1 ::1 localhost}))} {
                    error "questionApiUrl must be HTTPS (or loopback HTTP), without credentials, query or fragment"
                }
                set options [ns_json parse -output triples -maxdepth 16 -maxstring 16384 -maxcontainer 1000 -- [ns_config ${:section} questionApiOptions {{}}]]
                if {[lindex $options 1] ne "object"} {
                    error "questionApiOptions must be a JSON object"
                }
                foreach {key type value} [lindex $options 2] {
                    if {$key in {model messages tools tool_choice stream stream_options max_tokens max_completion_tokens n}} {
                        error "questionApiOptions cannot override $key"
                    }
                }
                set maxResponse [ns_baseunit -size [ns_config ${:section} questionMaxResponseBytes 8MiB]]
                if {$maxResponse < 1024} {
                    error "questionMaxResponseBytes must be at least 1KiB"
                }
                ::nsmcp::ChatCompletionsBackend create $object  -apiUrl $url -apiKeyFile $apiKeyFile -tokenFile $tokenFile -model $model -apiOptions $options  -timeout $timeout -maxResultBytes $maxResult -maxHistoryBytes [expr {$maxBytes/2}]  -maxResponseBytes $maxResponse  -maxOutputTokens [ns_config -int -min 1 -max 131072 ${:section} questionMaxOutputTokens 4096]  -maxToolCalls [ns_config -int -min 1 -max 128 ${:section} questionMaxToolCalls 32]  -maxApiRequests [ns_config -int -min 1 -max 64 ${:section} questionMaxApiRequests 16]
            }
        }
        return $object
    }

    # Read this virtual server's configuration and instantiate its service.
    # Alternatives may be unavailable; a configured default must initialize.
    :public object method 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.
        #
        # @param moduleDirectory Installed module directory used to resolve backend
        #        helpers.
        #
        # @return The startup setup result, or an empty result when questions are
        #         disabled.
        #

        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]  -jobStore nsmcp:mcp-log-jobs:[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]
    }
XQL Not present:
Generic, PostgreSQL, Oracle
[ hide source ] | [ make this the default ]
Show another procedure: