Class ::nsmcp::QuestionService

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

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

Class Relations

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

Methods (to be applied on the object)

  • setup (scripted, public)

     nsmcp::QuestionService[i] 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[i]> 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[i]> 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[i]> describe

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

     <instance of nsmcp::QuestionService[i]> event handle turn event
    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[i]> 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[i]> 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[i]> 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[i]> 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.
    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[i]> 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]