deepclause-sdk 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -3
- package/dist/agent.d.ts +17 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +148 -42
- package/dist/agent.js.map +1 -1
- package/dist/cli/config.d.ts +12 -12
- package/dist/cli/repl.d.ts +13 -1
- package/dist/cli/repl.d.ts.map +1 -1
- package/dist/cli/repl.js +145 -4
- package/dist/cli/repl.js.map +1 -1
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +3 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/forms/registry.d.ts +119 -0
- package/dist/forms/registry.d.ts.map +1 -0
- package/dist/forms/registry.js +256 -0
- package/dist/forms/registry.js.map +1 -0
- package/dist/forms/spec.d.ts +193 -0
- package/dist/forms/spec.d.ts.map +1 -0
- package/dist/forms/spec.js +295 -0
- package/dist/forms/spec.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/prolog/loader.d.ts +18 -0
- package/dist/prolog/loader.d.ts.map +1 -1
- package/dist/prolog/loader.js +26 -1
- package/dist/prolog/loader.js.map +1 -1
- package/dist/prolog-src/deepclause_mi.pl +619 -28
- package/dist/repl/agent.d.ts +4 -0
- package/dist/repl/agent.d.ts.map +1 -1
- package/dist/repl/agent.js +2 -0
- package/dist/repl/agent.js.map +1 -1
- package/dist/repl/form-widget.d.ts +56 -0
- package/dist/repl/form-widget.d.ts.map +1 -0
- package/dist/repl/form-widget.js +289 -0
- package/dist/repl/form-widget.js.map +1 -0
- package/dist/repl/harness-tools.d.ts +3 -1
- package/dist/repl/harness-tools.d.ts.map +1 -1
- package/dist/repl/harness-tools.js +13 -56
- package/dist/repl/harness-tools.js.map +1 -1
- package/dist/repl/history.d.ts +2 -1
- package/dist/repl/history.d.ts.map +1 -1
- package/dist/repl/history.js +34 -8
- package/dist/repl/history.js.map +1 -1
- package/dist/repl/index.d.ts +14 -2
- package/dist/repl/index.d.ts.map +1 -1
- package/dist/repl/index.js +6 -0
- package/dist/repl/index.js.map +1 -1
- package/dist/repl/process-backend.d.ts +2 -2
- package/dist/repl/process-backend.d.ts.map +1 -1
- package/dist/repl/process-executor.d.ts +11 -2
- package/dist/repl/process-executor.d.ts.map +1 -1
- package/dist/repl/process-executor.js +67 -18
- package/dist/repl/process-executor.js.map +1 -1
- package/dist/repl/process-worker.d.ts +35 -4
- package/dist/repl/process-worker.d.ts.map +1 -1
- package/dist/repl/process-worker.js +78 -38
- package/dist/repl/process-worker.js.map +1 -1
- package/dist/repl/processes.d.ts +4 -0
- package/dist/repl/processes.d.ts.map +1 -1
- package/dist/repl/processes.js +20 -0
- package/dist/repl/processes.js.map +1 -1
- package/dist/repl/render.d.ts +17 -0
- package/dist/repl/render.d.ts.map +1 -1
- package/dist/repl/render.js +50 -0
- package/dist/repl/render.js.map +1 -1
- package/dist/repl/session.d.ts +105 -7
- package/dist/repl/session.d.ts.map +1 -1
- package/dist/repl/session.js +219 -15
- package/dist/repl/session.js.map +1 -1
- package/dist/repl/tui/form-view.d.ts +17 -0
- package/dist/repl/tui/form-view.d.ts.map +1 -0
- package/dist/repl/tui/form-view.js +25 -0
- package/dist/repl/tui/form-view.js.map +1 -0
- package/dist/repl/tui/renderer.d.ts +4 -0
- package/dist/repl/tui/renderer.d.ts.map +1 -1
- package/dist/repl/tui/renderer.js +12 -0
- package/dist/repl/tui/renderer.js.map +1 -1
- package/dist/repl/worker-host.d.ts +16 -2
- package/dist/repl/worker-host.d.ts.map +1 -1
- package/dist/repl/worker-host.js +38 -6
- package/dist/repl/worker-host.js.map +1 -1
- package/dist/repl/worker-protocol.d.ts +14 -0
- package/dist/repl/worker-protocol.d.ts.map +1 -1
- package/dist/repl/worker-protocol.js +8 -1
- package/dist/repl/worker-protocol.js.map +1 -1
- package/dist/runner.d.ts +63 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +350 -40
- package/dist/runner.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +12 -0
- package/dist/sdk.js.map +1 -1
- package/dist/system/assets/docs/AGENT_PROMPT.md +387 -18
- package/dist/system/assets/docs/CHAT_PROMPT.md +28 -0
- package/dist/system/assets/docs/DML_AUTHORING.md +1 -1
- package/dist/system/assets/docs/DML_FORMS.md +740 -0
- package/dist/system/assets/docs/DML_REFERENCE.md +180 -4
- package/dist/system/assets/docs/HARNESS_EXAMPLES.md +94 -0
- package/dist/system/assets/index.d.ts +2 -2
- package/dist/system/assets/index.d.ts.map +1 -1
- package/dist/system/assets/index.js +4 -0
- package/dist/system/assets/index.js.map +1 -1
- package/dist/system/environment.d.ts +6 -0
- package/dist/system/environment.d.ts.map +1 -1
- package/dist/system/environment.js +5 -4
- package/dist/system/environment.js.map +1 -1
- package/dist/types.d.ts +35 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/prolog-src/deepclause_mi.pl +619 -28
package/dist/runner.js
CHANGED
|
@@ -5,6 +5,7 @@ import { mountWorkspace } from './prolog/loader.js';
|
|
|
5
5
|
import { runAgentLoop } from './agent.js';
|
|
6
6
|
import { takePrologMessages } from './prolog/loader.js';
|
|
7
7
|
import { validateToolArguments } from './repl/tool-arguments.js';
|
|
8
|
+
import { normalizeSpec, validateValues } from './forms/spec.js';
|
|
8
9
|
import { judgeAnswersToValues, judgeCacheKey, normalizeJudgeState, parseJudgeQuestions, validateJudgeAnswers, valuesToPrologList } from './judge/marshal.js';
|
|
9
10
|
import { buildToolCompletionEvent, buildToolFailureEvent, buildToolStartEvent, } from './system/runtime/shell-tool-events.js';
|
|
10
11
|
import { checkToolPolicy } from './tools.js';
|
|
@@ -137,6 +138,37 @@ export function decodeAgentOutputVars(rawOutputVars) {
|
|
|
137
138
|
return String(value);
|
|
138
139
|
});
|
|
139
140
|
}
|
|
141
|
+
/** Decode `issue{path, level, message}` dicts from Prolog into `FormIssue[]`. */
|
|
142
|
+
function toFormIssues(raw) {
|
|
143
|
+
const value = toJsValue(raw);
|
|
144
|
+
if (!Array.isArray(value))
|
|
145
|
+
return [];
|
|
146
|
+
return value.map((entry) => {
|
|
147
|
+
const obj = toJsValue(entry);
|
|
148
|
+
return {
|
|
149
|
+
path: String(obj?.path ?? ''),
|
|
150
|
+
level: obj?.level === 'warning' ? 'warning' : 'error',
|
|
151
|
+
message: String(obj?.message ?? ''),
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Does this action run the harness's hard rules? `submit` by default; an action
|
|
157
|
+
* may opt out with `validates: []` (or opt in with `all`/`hard`/a path list).
|
|
158
|
+
*/
|
|
159
|
+
function shouldValidate(spec, action) {
|
|
160
|
+
if (['cancel', 'closed', 'timeout', 'unavailable', 'cancelled', 'invalid'].includes(action))
|
|
161
|
+
return false;
|
|
162
|
+
const declared = spec?.actions?.find((entry) => entry.name === action);
|
|
163
|
+
if (!declared)
|
|
164
|
+
return action === 'submit';
|
|
165
|
+
const validates = declared.validates;
|
|
166
|
+
if (validates === undefined)
|
|
167
|
+
return action === 'submit';
|
|
168
|
+
if (Array.isArray(validates))
|
|
169
|
+
return validates.length > 0;
|
|
170
|
+
return validates === 'all' || validates === 'hard';
|
|
171
|
+
}
|
|
140
172
|
/**
|
|
141
173
|
* User tool info from Prolog
|
|
142
174
|
*/
|
|
@@ -201,6 +233,19 @@ export class DMLRunner {
|
|
|
201
233
|
sessionId = '';
|
|
202
234
|
currentMemory = [];
|
|
203
235
|
stepCounter = 0;
|
|
236
|
+
/**
|
|
237
|
+
* Conversation inbox (docs/DML_CONVERSATION.md §5).
|
|
238
|
+
*
|
|
239
|
+
* A conversation waits on two sources that a harness cannot see at once: the
|
|
240
|
+
* person (the host's input channel) and the session's mailbox (a worker's
|
|
241
|
+
* message). The runner merges them here, so `next/1` is one blocking read and
|
|
242
|
+
* `input/1` is the same read restricted to the person. Items are queued, never
|
|
243
|
+
* dropped: an event that arrives while the person is typing waits its turn.
|
|
244
|
+
*/
|
|
245
|
+
chatInbox = [];
|
|
246
|
+
/** One outstanding host input request at a time, reused across waits. */
|
|
247
|
+
chatInputPending = null;
|
|
248
|
+
chatInboxWake = null;
|
|
204
249
|
judgeCache = new Map();
|
|
205
250
|
/**
|
|
206
251
|
* DML tools currently executing, innermost last.
|
|
@@ -433,6 +478,37 @@ export class DMLRunner {
|
|
|
433
478
|
}
|
|
434
479
|
break;
|
|
435
480
|
}
|
|
481
|
+
case 'request_form': {
|
|
482
|
+
// A form operation. The host owns the registry; the result is posted
|
|
483
|
+
// back so Prolog/MI keeps the encoding (docs/DML_FORMS.md §7).
|
|
484
|
+
const payload = (step.payload ?? {});
|
|
485
|
+
const event = this.handleFormRequest(payload, options.forms);
|
|
486
|
+
if (event) {
|
|
487
|
+
yield event;
|
|
488
|
+
// `update` re-presents too: a rejected submit (or a model editing the
|
|
489
|
+
// form) must reach the person filling it in, with the errors on it.
|
|
490
|
+
if ((event.form?.op === 'open' || event.form?.op === 'update') && options.onForm) {
|
|
491
|
+
await options.onForm(event.form);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case 'wait_form': {
|
|
497
|
+
const payload = (step.payload ?? {});
|
|
498
|
+
const formId = String(toJsValue(payload.formId) ?? '');
|
|
499
|
+
const since = Number(toJsValue(payload.since) ?? 0);
|
|
500
|
+
const timeoutMs = Number(toJsValue(payload.timeout) ?? 0);
|
|
501
|
+
this.postFormResult(await this.handleFormWait(formId, since, timeoutMs, options.forms, options.signal));
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case 'wait_next': {
|
|
505
|
+
const accept = String(toJsValue(step.payload?.accept) ?? 'any');
|
|
506
|
+
if (this.armChatInput(options))
|
|
507
|
+
yield { type: 'input_required', prompt: '' };
|
|
508
|
+
const item = await this.waitForChatItem(accept, options);
|
|
509
|
+
this.postNextResult(item.term);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
436
512
|
case 'request_sample_token':
|
|
437
513
|
yield* this.handleSampleToken(step.payload, options);
|
|
438
514
|
break;
|
|
@@ -715,6 +791,9 @@ export class DMLRunner {
|
|
|
715
791
|
options, _frame) {
|
|
716
792
|
const rawPayload = payload;
|
|
717
793
|
const taskDescription = String(toJsValue(rawPayload.taskDescription) ?? '');
|
|
794
|
+
// `chat` is a conversational turn: tools, no finish, ends when the model
|
|
795
|
+
// stops calling tools (see agent.ts `conversational`).
|
|
796
|
+
const conversational = String(toJsValue(rawPayload.mode) ?? '') === 'chat';
|
|
718
797
|
const stepId = `step_${this.stepCounter++}`;
|
|
719
798
|
yield { type: 'task_activity', taskState: 'started', taskDescription, taskId: stepId };
|
|
720
799
|
let stepSucceeded = null;
|
|
@@ -875,16 +954,40 @@ export class DMLRunner {
|
|
|
875
954
|
toolPolicy: options.toolPolicy,
|
|
876
955
|
onModelCall: options.onModelCall,
|
|
877
956
|
onProcessRequest: options.onProcessRequest,
|
|
957
|
+
forms: options.forms,
|
|
958
|
+
onForm: options.onForm,
|
|
878
959
|
}, withheld, emitToolCall, emitToolEvent);
|
|
879
960
|
}, withheld);
|
|
880
961
|
// Run agent loop with streaming support
|
|
881
962
|
const resultPromise = runAgentLoop({
|
|
882
963
|
taskDescription,
|
|
883
964
|
outputVars,
|
|
965
|
+
conversational,
|
|
966
|
+
// A chat turn does not end while a form it opened is still unanswered: the
|
|
967
|
+
// model may have rendered the form and then replied without waiting (it
|
|
968
|
+
// does not have to know the two-call protocol). Wait for the submission and
|
|
969
|
+
// hand it back as the next turn's input, so nothing is lost.
|
|
970
|
+
onIdleTurn: async () => {
|
|
971
|
+
if (!conversational || !options.forms)
|
|
972
|
+
return null;
|
|
973
|
+
const awaiting = options.forms.awaitingForms();
|
|
974
|
+
if (awaiting.length === 0)
|
|
975
|
+
return null;
|
|
976
|
+
const waits = await Promise.all(awaiting.map((record) => options.forms.wait(record.formId, -1, { signal: options.signal })));
|
|
977
|
+
return waits
|
|
978
|
+
.map((change) => {
|
|
979
|
+
const values = JSON.stringify(change.values ?? {});
|
|
980
|
+
return change.action === 'submit'
|
|
981
|
+
? `The person submitted the form ${change.formId}: ${values}`
|
|
982
|
+
: `The person dismissed the form ${change.formId} (action: ${change.action}).`;
|
|
983
|
+
})
|
|
984
|
+
.join('\n');
|
|
985
|
+
},
|
|
884
986
|
memory: effectiveMemory,
|
|
885
987
|
tools: availableTools,
|
|
886
988
|
workspacePath: options.workspacePath,
|
|
887
989
|
environment: this.options.environment,
|
|
990
|
+
capabilityTool: this.options.capabilityTool,
|
|
888
991
|
resolveMedia: (refs) => this.readMediaRefs(options.workspacePath, refs),
|
|
889
992
|
modelOptions: {
|
|
890
993
|
model: scopedModel?.id ?? this.options.model,
|
|
@@ -950,8 +1053,12 @@ export class DMLRunner {
|
|
|
950
1053
|
signal: options.signal,
|
|
951
1054
|
llmBackend: this.options.llmBackend,
|
|
952
1055
|
});
|
|
953
|
-
//
|
|
954
|
-
|
|
1056
|
+
// Drain host events as they arrive. This must run even when not streaming:
|
|
1057
|
+
// a tool can block on a host request — a form wait, an `ask_user` — and that
|
|
1058
|
+
// request must reach the consumer *before* the tool returns, or the run
|
|
1059
|
+
// deadlocks. Non-streaming queues no stream/reasoning chunks, so yielding
|
|
1060
|
+
// everything here is equivalent.
|
|
1061
|
+
{
|
|
955
1062
|
while (true) {
|
|
956
1063
|
// Process any queued events
|
|
957
1064
|
while (streamQueue.length > 0) {
|
|
@@ -959,18 +1066,35 @@ export class DMLRunner {
|
|
|
959
1066
|
}
|
|
960
1067
|
// Check if result is ready
|
|
961
1068
|
const raceResult = await Promise.race([
|
|
962
|
-
resultPromise.then(r => ({ type: 'done', result: r })),
|
|
1069
|
+
resultPromise.then(r => ({ type: 'done', result: r }), (error) => ({ type: 'failed', error })),
|
|
963
1070
|
new Promise(resolve => {
|
|
964
1071
|
streamResolve = () => resolve({ type: 'stream' });
|
|
965
1072
|
}),
|
|
966
1073
|
]);
|
|
1074
|
+
if (raceResult.type === 'failed') {
|
|
1075
|
+
const message = raceResult.error instanceof Error
|
|
1076
|
+
? raceResult.error.message
|
|
1077
|
+
: String(raceResult.error);
|
|
1078
|
+
if (!conversational)
|
|
1079
|
+
throw raceResult.error;
|
|
1080
|
+
// A conversation must survive a failed turn: report it and keep the
|
|
1081
|
+
// memory as it was, so the person can simply type again. Failing the
|
|
1082
|
+
// turn here would end `loop :- input(A), chat(A), loop.`
|
|
1083
|
+
this.postAgentResult({ success: false, outputs: [], variables: {}, messages: [] }, preparedMemory.filter((entry) => entry.role === 'system'), message);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
967
1086
|
if (raceResult.type === 'done') {
|
|
968
1087
|
// Drain remaining queue
|
|
969
1088
|
while (streamQueue.length > 0) {
|
|
970
1089
|
yield streamQueue.shift();
|
|
971
1090
|
}
|
|
972
|
-
// In streaming mode, output was already shown via onStream - don't
|
|
973
|
-
// The outputs array is kept for memory/history purposes
|
|
1091
|
+
// In streaming mode, output was already shown via onStream - don't
|
|
1092
|
+
// repeat it. The outputs array is kept for memory/history purposes.
|
|
1093
|
+
if (!this.options.streaming) {
|
|
1094
|
+
for (const output of raceResult.result.outputs) {
|
|
1095
|
+
yield { type: 'output', content: output };
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
974
1098
|
const finalizedMemory = [
|
|
975
1099
|
...preparedMemory.filter((message) => message.role === 'system'),
|
|
976
1100
|
...raceResult.result.messages,
|
|
@@ -988,39 +1112,6 @@ export class DMLRunner {
|
|
|
988
1112
|
// Otherwise continue loop to yield queued stream events
|
|
989
1113
|
}
|
|
990
1114
|
}
|
|
991
|
-
else {
|
|
992
|
-
// Non-streaming mode - wait for completion
|
|
993
|
-
const result = await resultPromise;
|
|
994
|
-
// Drain tool_call events from queue (they were queued during agent execution)
|
|
995
|
-
if (process.env.DEBUG_QUEUE) {
|
|
996
|
-
console.log('[RUNNER] Non-streaming: streamQueue length before drain:', streamQueue.length);
|
|
997
|
-
if (streamQueue.length > 0) {
|
|
998
|
-
console.log('[RUNNER] streamQueue contents:', streamQueue.map(e => `${e.type}:${e.content?.substring(0, 40)}`));
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
while (streamQueue.length > 0) {
|
|
1002
|
-
yield streamQueue.shift();
|
|
1003
|
-
}
|
|
1004
|
-
if (process.env.DEBUG_QUEUE) {
|
|
1005
|
-
console.log('[RUNNER] Non-streaming agent result.outputs:', result.outputs);
|
|
1006
|
-
}
|
|
1007
|
-
// Stream any output from the agent
|
|
1008
|
-
for (const output of result.outputs) {
|
|
1009
|
-
yield { type: 'output', content: output };
|
|
1010
|
-
}
|
|
1011
|
-
const finalizedMemory = [
|
|
1012
|
-
...preparedMemory.filter((message) => message.role === 'system'),
|
|
1013
|
-
...result.messages,
|
|
1014
|
-
];
|
|
1015
|
-
while (streamQueue.length > 0) {
|
|
1016
|
-
yield streamQueue.shift();
|
|
1017
|
-
}
|
|
1018
|
-
// Update memory with full agent conversation
|
|
1019
|
-
this.currentMemory = finalizedMemory;
|
|
1020
|
-
// Post result back to Prolog
|
|
1021
|
-
stepSucceeded = result.success;
|
|
1022
|
-
this.postAgentResult(result, finalizedMemory);
|
|
1023
|
-
}
|
|
1024
1115
|
}
|
|
1025
1116
|
finally {
|
|
1026
1117
|
yield {
|
|
@@ -1842,6 +1933,7 @@ export class DMLRunner {
|
|
|
1842
1933
|
// Extract memory
|
|
1843
1934
|
const memory = this.extractMemoryFromPayload(payload);
|
|
1844
1935
|
const preparedMemory = memory;
|
|
1936
|
+
const nestedConversational = String(toJsValue(payload?.mode) ?? '') === 'chat';
|
|
1845
1937
|
// Extract reasoning effort and recipe context for nested loops
|
|
1846
1938
|
const nestedReasoningEffort = String(toJsValue(payload?.reasoningEffort) ?? 'none');
|
|
1847
1939
|
const nestedRecipeContext = String(toJsValue(payload?.recipeContext) ?? '');
|
|
@@ -1884,11 +1976,13 @@ export class DMLRunner {
|
|
|
1884
1976
|
const result = await runAgentLoop({
|
|
1885
1977
|
taskDescription,
|
|
1886
1978
|
outputVars,
|
|
1979
|
+
conversational: nestedConversational,
|
|
1887
1980
|
memory: nestedEffectiveMemory,
|
|
1888
1981
|
tools: availableTools,
|
|
1889
1982
|
// Withheld to break a call cycle; declared to the model so a
|
|
1890
1983
|
// call can be answered with the reason rather than "not found".
|
|
1891
1984
|
excludedTools: nestedExcludedTools,
|
|
1985
|
+
capabilityTool: this.options.capabilityTool,
|
|
1892
1986
|
modelOptions: {
|
|
1893
1987
|
model: this.options.model,
|
|
1894
1988
|
provider: this.options.provider,
|
|
@@ -1988,7 +2082,41 @@ export class DMLRunner {
|
|
|
1988
2082
|
}
|
|
1989
2083
|
break;
|
|
1990
2084
|
}
|
|
2085
|
+
case 'request_form': {
|
|
2086
|
+
// A DML tool reached a form primitive. Resolve it through the same
|
|
2087
|
+
// host registry as the enclosing run (docs/DML_FORMS.md §4).
|
|
2088
|
+
const formPayload = (payload ?? {});
|
|
2089
|
+
const event = this.handleFormRequest(formPayload, runContext.forms);
|
|
2090
|
+
if (event) {
|
|
2091
|
+
onToolEvent?.(event);
|
|
2092
|
+
// The host interaction must run here too: a form opened by a tool
|
|
2093
|
+
// (`render_form`) is the common case, and without this nothing
|
|
2094
|
+
// prompts and the run just blocks on the wait.
|
|
2095
|
+
if ((event.form?.op === 'open' || event.form?.op === 'update') && runContext.onForm) {
|
|
2096
|
+
await runContext.onForm(event.form);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
break;
|
|
2100
|
+
}
|
|
2101
|
+
case 'wait_form': {
|
|
2102
|
+
const formPayload = (payload ?? {});
|
|
2103
|
+
const formId = String(toJsValue(formPayload.formId) ?? '');
|
|
2104
|
+
const since = Number(toJsValue(formPayload.since) ?? 0);
|
|
2105
|
+
const timeoutMs = Number(toJsValue(formPayload.timeout) ?? 0);
|
|
2106
|
+
this.postFormResult(await this.handleFormWait(formId, since, timeoutMs, runContext.forms, runContext.signal));
|
|
2107
|
+
break;
|
|
2108
|
+
}
|
|
2109
|
+
case 'wait_next': {
|
|
2110
|
+
// A conversation turn inside a tool (rare, but the same contract).
|
|
2111
|
+
const accept = String(toJsValue(payload?.accept) ?? 'any');
|
|
2112
|
+
const item = await this.waitForChatItem(accept, runContext);
|
|
2113
|
+
this.postNextResult(item.term);
|
|
2114
|
+
break;
|
|
2115
|
+
}
|
|
1991
2116
|
case 'error': {
|
|
2117
|
+
// A tool-engine error (a thrown body, or a tool that returned no
|
|
2118
|
+
// result). Report it as a failure so it is not mistaken for success.
|
|
2119
|
+
onToolEvent?.(buildToolFailureEvent(toolName, {}, new Error(content)));
|
|
1992
2120
|
return { result: { error: content }, outputs };
|
|
1993
2121
|
}
|
|
1994
2122
|
default: {
|
|
@@ -2074,7 +2202,7 @@ export class DMLRunner {
|
|
|
2074
2202
|
/**
|
|
2075
2203
|
* Post agent loop result back to Prolog
|
|
2076
2204
|
*/
|
|
2077
|
-
postAgentResult(result, fullMemory) {
|
|
2205
|
+
postAgentResult(result, fullMemory, error) {
|
|
2078
2206
|
// Keys need to be quoted because they start with uppercase (Var1, Var2, etc.)
|
|
2079
2207
|
const varsStr = Object.entries(result.variables)
|
|
2080
2208
|
.map(([k, v]) => `'${k}': ${this.toPrologTerm(v)}`)
|
|
@@ -2082,12 +2210,194 @@ export class DMLRunner {
|
|
|
2082
2210
|
// Convert messages to Prolog list format
|
|
2083
2211
|
const messagesStr = this.serializeMemoryMessages(result.messages);
|
|
2084
2212
|
const fullMemoryStr = this.serializeMemoryMessages(fullMemory ?? []);
|
|
2085
|
-
this.query(`deepclause_mi:post_agent_result('${this.sessionId}', ${result.success}, vars{${varsStr}}, [${messagesStr}], [${fullMemoryStr}])`);
|
|
2213
|
+
this.query(`deepclause_mi:post_agent_result('${this.sessionId}', ${result.success}, vars{${varsStr}}, [${messagesStr}], [${fullMemoryStr}], ${this.toPrologTerm(error ?? '')})`);
|
|
2086
2214
|
}
|
|
2087
2215
|
/**
|
|
2088
2216
|
* Post a process primitive result back to Prolog under a named slot.
|
|
2089
2217
|
* `term` is a raw Prolog term string, so the caller controls encoding.
|
|
2090
2218
|
*/
|
|
2219
|
+
/**
|
|
2220
|
+
* Apply a form operation and return the `form` event to emit, if any.
|
|
2221
|
+
* The registry owns state; the result is posted back to Prolog as a term so
|
|
2222
|
+
* the MI keeps the encoding, like `request_proc` (docs/DML_FORMS.md §7).
|
|
2223
|
+
*/
|
|
2224
|
+
handleFormRequest(payload, forms) {
|
|
2225
|
+
if (!forms) {
|
|
2226
|
+
this.postFormResult('error(no_form_host)');
|
|
2227
|
+
return undefined;
|
|
2228
|
+
}
|
|
2229
|
+
const op = String(toJsValue(payload.op) ?? '');
|
|
2230
|
+
if (op === 'open') {
|
|
2231
|
+
// A caller (a harness, or a model that designed the form) may leave out the
|
|
2232
|
+
// boilerplate: `version`, and each field's `type`.
|
|
2233
|
+
const spec = normalizeSpec(toJsValue(payload.spec));
|
|
2234
|
+
const opened = forms.open(spec);
|
|
2235
|
+
this.postFormResult(`ok(${this.toPrologTerm({ formId: opened.formId, revision: opened.revision })})`);
|
|
2236
|
+
return {
|
|
2237
|
+
type: 'form',
|
|
2238
|
+
form: { op: 'open', formId: opened.formId, revision: opened.revision, spec, values: opened.values, issues: [] },
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
const formId = String(toJsValue(payload.formId) ?? '');
|
|
2242
|
+
if (op === 'update') {
|
|
2243
|
+
const patch = (toJsValue(payload.patch) ?? {});
|
|
2244
|
+
forms.update(formId, patch);
|
|
2245
|
+
this.postFormResult('ok');
|
|
2246
|
+
const record = forms.get(formId);
|
|
2247
|
+
// `spec` travels with every form event: a renderer that only gets an update
|
|
2248
|
+
// still needs the fields to draw (and to show the issues against them).
|
|
2249
|
+
return record
|
|
2250
|
+
? { type: 'form', form: { op: 'update', formId, revision: record.revision, spec: record.spec, patch, values: record.values, issues: record.issues } }
|
|
2251
|
+
: undefined;
|
|
2252
|
+
}
|
|
2253
|
+
if (op === 'errors') {
|
|
2254
|
+
const issues = toFormIssues(payload.issues);
|
|
2255
|
+
forms.setIssues(formId, issues);
|
|
2256
|
+
this.postFormResult('ok');
|
|
2257
|
+
const record = forms.get(formId);
|
|
2258
|
+
return record
|
|
2259
|
+
? { type: 'form', form: { op: 'update', formId, revision: record.revision, spec: record.spec, values: record.values, issues } }
|
|
2260
|
+
: undefined;
|
|
2261
|
+
}
|
|
2262
|
+
if (op === 'close') {
|
|
2263
|
+
forms.close(formId, 'submitted');
|
|
2264
|
+
this.postFormResult('ok');
|
|
2265
|
+
const record = forms.get(formId);
|
|
2266
|
+
return { type: 'form', form: { op: 'close', formId, revision: record?.revision ?? 0, state: 'submitted' } };
|
|
2267
|
+
}
|
|
2268
|
+
this.postFormResult(`error(unknown_form_op(${op}))`);
|
|
2269
|
+
return undefined;
|
|
2270
|
+
}
|
|
2271
|
+
/** Await a form change and return the result term to post to Prolog. */
|
|
2272
|
+
/**
|
|
2273
|
+
* Wait for a UI change, enforcing the rules the host owns.
|
|
2274
|
+
*
|
|
2275
|
+
* A submit the rules reject comes back as `invalid` with the errors. The DML
|
|
2276
|
+
* side (see `mi_call(await_form(...))`) turns that into "show the errors, wait
|
|
2277
|
+
* again", so neither a harness nor a model ever acts on a refused submit — and
|
|
2278
|
+
* neither has to write the re-show loop.
|
|
2279
|
+
*/
|
|
2280
|
+
async handleFormWait(formId, since, timeoutMs, forms, signal) {
|
|
2281
|
+
if (!forms)
|
|
2282
|
+
return 'error(no_form_host)';
|
|
2283
|
+
try {
|
|
2284
|
+
const change = await forms.wait(formId, since, {
|
|
2285
|
+
timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
|
|
2286
|
+
signal,
|
|
2287
|
+
});
|
|
2288
|
+
// Layer 1: the spec's own constraints (required, enum, min/max, pattern).
|
|
2289
|
+
// Layer 2: the harness's `form_validate/2` domain rules. Both block.
|
|
2290
|
+
const record = forms.get(formId);
|
|
2291
|
+
if (record && shouldValidate(record.spec, change.action)) {
|
|
2292
|
+
const errors = [
|
|
2293
|
+
...validateValues(record.spec, change.values),
|
|
2294
|
+
...this.validateFormValues(change.values),
|
|
2295
|
+
];
|
|
2296
|
+
if (errors.length > 0) {
|
|
2297
|
+
return `ok(${this.toPrologTerm({ ...change, action: 'invalid', errors })})`;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
return `ok(${this.toPrologTerm(change)})`;
|
|
2301
|
+
}
|
|
2302
|
+
catch (error) {
|
|
2303
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2304
|
+
return `error(${JSON.stringify(message)})`;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
/** Run the harness's `form_validate/2` and return the issue dicts it produced. */
|
|
2308
|
+
validateFormValues(values) {
|
|
2309
|
+
const result = this.query(`deepclause_mi:form_validate_values('${this.sessionId}', ${this.toPrologTerm(values)}, Issues)`);
|
|
2310
|
+
return toFormIssues(result?.Issues);
|
|
2311
|
+
}
|
|
2312
|
+
// ---- Conversation inbox (docs/DML_CONVERSATION.md §5) ------------------
|
|
2313
|
+
/** Start the host's input request, unless one is already outstanding. */
|
|
2314
|
+
armChatInput(options) {
|
|
2315
|
+
if (this.chatInputPending)
|
|
2316
|
+
return false;
|
|
2317
|
+
if (!options.onInputRequired)
|
|
2318
|
+
return false;
|
|
2319
|
+
this.chatInputPending = options
|
|
2320
|
+
.onInputRequired('')
|
|
2321
|
+
.then((text) => {
|
|
2322
|
+
this.chatInputPending = null;
|
|
2323
|
+
// No answer means nobody is there: close the inbox, which ends a
|
|
2324
|
+
// conversation loop instead of spinning on empty turns.
|
|
2325
|
+
this.pushChatItem(text === undefined || text === null
|
|
2326
|
+
? { kind: 'closed', term: 'closed' }
|
|
2327
|
+
: { kind: 'user', term: `user(${this.toPrologTerm(text)})` });
|
|
2328
|
+
})
|
|
2329
|
+
.catch(() => {
|
|
2330
|
+
this.chatInputPending = null;
|
|
2331
|
+
this.pushChatItem({ kind: 'closed', term: 'closed' });
|
|
2332
|
+
});
|
|
2333
|
+
return true;
|
|
2334
|
+
}
|
|
2335
|
+
pushChatItem(item) {
|
|
2336
|
+
this.chatInbox.push(item);
|
|
2337
|
+
const wake = this.chatInboxWake;
|
|
2338
|
+
this.chatInboxWake = null;
|
|
2339
|
+
wake?.();
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* The next item the caller accepts; `user` skips (keeps) worker messages.
|
|
2343
|
+
* A closed inbox is the last resort: a message that arrived is worth more than
|
|
2344
|
+
* "nobody is typing", so it is handed out only when nothing else is queued.
|
|
2345
|
+
*/
|
|
2346
|
+
takeChatItem(accept) {
|
|
2347
|
+
const open = this.chatInbox.findIndex((item) => item.kind !== 'closed' && (accept === 'any' || item.kind === 'user'));
|
|
2348
|
+
if (open >= 0)
|
|
2349
|
+
return this.chatInbox.splice(open, 1)[0];
|
|
2350
|
+
const closed = this.chatInbox.findIndex((item) => item.kind === 'closed');
|
|
2351
|
+
return closed >= 0 ? this.chatInbox.splice(closed, 1)[0] : undefined;
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Wait for the next inbox item: the person's message, or one a worker sent this
|
|
2355
|
+
* session. The mailbox is polled with a short timeout rather than held open, so
|
|
2356
|
+
* a run that ends mid-wait leaves no receiver behind.
|
|
2357
|
+
*/
|
|
2358
|
+
async waitForChatItem(accept, options) {
|
|
2359
|
+
for (;;) {
|
|
2360
|
+
const ready = this.takeChatItem(accept);
|
|
2361
|
+
if (ready && ready.kind !== 'closed')
|
|
2362
|
+
return ready;
|
|
2363
|
+
if (ready?.kind === 'closed') {
|
|
2364
|
+
// The person is gone, but a worker's message may still be in flight: give
|
|
2365
|
+
// the mailbox one last chance so closing the input does not swallow it.
|
|
2366
|
+
await this.pollChatMessage(options);
|
|
2367
|
+
return this.takeChatItem(accept) ?? ready;
|
|
2368
|
+
}
|
|
2369
|
+
const wake = new Promise((resolve) => {
|
|
2370
|
+
this.chatInboxWake = resolve;
|
|
2371
|
+
});
|
|
2372
|
+
await Promise.race([wake, this.pollChatMessage(options)]);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
/** One mailbox poll, so the inbox sees worker messages as they arrive. */
|
|
2376
|
+
async pollChatMessage(options) {
|
|
2377
|
+
if (!options.onProcessRequest) {
|
|
2378
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
try {
|
|
2382
|
+
const result = await options.onProcessRequest({ op: 'receive', timeout: 200 }, options.signal);
|
|
2383
|
+
if (result.term.startsWith('ok(')) {
|
|
2384
|
+
// `ok(<term>)` — the message a worker sent to this session.
|
|
2385
|
+
this.pushChatItem({ kind: 'message', term: `message(${result.term.slice(3, -1)})` });
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
catch {
|
|
2389
|
+
// No process host in this run: the person is the only source.
|
|
2390
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
/** Post the conversation inbox item back to Prolog under its slot. */
|
|
2394
|
+
postNextResult(term) {
|
|
2395
|
+
this.query(`deepclause_mi:post_next_result('${this.sessionId}', ${term})`);
|
|
2396
|
+
}
|
|
2397
|
+
/** Post a form primitive result back to Prolog under the form slot. */
|
|
2398
|
+
postFormResult(term) {
|
|
2399
|
+
this.query(`deepclause_mi:post_form_result('${this.sessionId}', ${term})`);
|
|
2400
|
+
}
|
|
2091
2401
|
postProcessResult(key, term) {
|
|
2092
2402
|
const atom = /^[a-z][A-Za-z0-9_]*$/.test(key) ? key : `'${key.replace(/'/g, "''")}'`;
|
|
2093
2403
|
this.query(`deepclause_mi:post_proc_result('${this.sessionId}', ${atom}, ${term})`);
|