Class ::nsmcp::QuestionService
::nsmcp::QuestionService
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 ] \
[ -queue:required queue:required ] \
[ -store:required store:required ]
Defined in /usr/local/ns/tcl/nsmcp/question-service.tclClass Relations
- class: ::nx::Class
![[i]](/resources/acs-subsite/ZoomIn16.gif)
- superclass: ::nx::Object
![[i]](/resources/acs-subsite/ZoomIn16.gif)
::nx::Class create ::nsmcp::QuestionService \
-superclass ::nx::Object
Methods (to be applied on the object)
setup (scripted, public)
nsmcp::QuestionService
setup 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 -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] {
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}}
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
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 ]
- Parameters:
- context (required)
- question (required)
- handle (optional)
- backendName (optional)
- Testcases:
-
No testcase defined.
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"}
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})}]
dict lappend record turns [dict create id $turn question $question answer {} status queued tools {} error {}]
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
return -options $options $message
}
} finally {ns_mutex unlock ${:mutex}}
return [:poll $context $handle]