- Methods: All Methods Documented Methods Hide Methods
- Source: Display Source Hide Source
- Variables: Show Variables Hide Variables
Class ::nsmcp::QuestionService
::nsmcp::QuestionServicecreate ... \
[ -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
::nx::Class create ::nsmcp::QuestionService \ -superclass ::nx::ObjectMethods (to be applied on the object)
setup (scripted, public)
nsmcp::QuestionServicesetup moduleDirectory
- Parameters:
- moduleDirectory (required)
- 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> cancel context handle
- Parameters:
- context (required)
- handle (required)
- 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> choices
- 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]describe (scripted, public)
<instance of nsmcp::QuestionService> describe
- Testcases:
- No testcase defined.
return [dict merge [${:backend} describe] [dict create maxBytes ${:maxBytes} maxEvidenceBytes ${:maxEvidenceBytes}]]event (scripted, public)
<instance of nsmcp::QuestionService> event handle turn event
- Parameters:
- handle (required)
- turn (required)
- event (required)
- 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> evidence context handle id
- Parameters:
- context (required)
- handle (required)
- id (required)
- 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> forget context handle
- Parameters:
- context (required)
- handle (required)
- 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> poll context handle
- Parameters:
- context (required)
- handle (required)
- Testcases:
- No testcase defined.
ns_mutex lock ${:mutex} try {set record [:owned $context $handle]} finally {ns_mutex unlock ${:mutex}} 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]] 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 {examinedBytes sourceBytes} { if {[dict exists $job progress $key]} {lappend progress $key number [dict get $job progress $key]} } lappend fields progress object $progress } lappend jobs $k object $fields incr k } } lappend turns $i object [list id string [dict get $entry id] question string [dict get $entry question] answer string [dict get $entry answer] status string [dict get $entry status] error string [dict get $entry error] tools array $tools jobs array $jobs] 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> run handle turn
- Parameters:
- handle (required)
- turn (required)
- 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 question [dict get [lindex [dict get $record turns] end] 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 :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> send context question \ [ handle ] [ backendName ] [ browserTimezone ] [ peerAddress ]
Queue a question or follow-up with optional browser timezone context.
- Parameters:
- context (required)
- Authenticated owner and server context.
- question (required)
- Original question text, preserved for display.
- handle (optional)
- Existing conversation handle, or empty for a new conversation.
- backendName (optional)
- Backend selection for a new conversation, or empty for its default.
- browserTimezone (optional)
- Valid timezone used as the default unless the question overrides it; empty retains prior context or UTC.
- peerAddress (optional)
- Direct connection peer supplied by the authenticated HTTP handler, or empty for non-HTTP callers.
- 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"} 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 {[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 {[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]
- Methods: All Methods Documented Methods Hide Methods
- Source: Display Source Hide Source
- Variables: Show Variables Hide Variables
![[i]](/resources/acs-subsite/ZoomIn16.gif)