deepclause-sdk 0.2.1 → 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.
Files changed (114) hide show
  1. package/README.md +72 -3
  2. package/dist/agent.d.ts +17 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +148 -42
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli/config.d.ts +12 -12
  7. package/dist/cli/repl.d.ts +13 -1
  8. package/dist/cli/repl.d.ts.map +1 -1
  9. package/dist/cli/repl.js +146 -5
  10. package/dist/cli/repl.js.map +1 -1
  11. package/dist/forms/index.d.ts +3 -0
  12. package/dist/forms/index.d.ts.map +1 -0
  13. package/dist/forms/index.js +3 -0
  14. package/dist/forms/index.js.map +1 -0
  15. package/dist/forms/registry.d.ts +119 -0
  16. package/dist/forms/registry.d.ts.map +1 -0
  17. package/dist/forms/registry.js +256 -0
  18. package/dist/forms/registry.js.map +1 -0
  19. package/dist/forms/spec.d.ts +193 -0
  20. package/dist/forms/spec.d.ts.map +1 -0
  21. package/dist/forms/spec.js +295 -0
  22. package/dist/forms/spec.js.map +1 -0
  23. package/dist/index.d.ts +7 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/prolog/loader.d.ts +18 -0
  28. package/dist/prolog/loader.d.ts.map +1 -1
  29. package/dist/prolog/loader.js +26 -1
  30. package/dist/prolog/loader.js.map +1 -1
  31. package/dist/prolog-src/deepclause_mi.pl +650 -28
  32. package/dist/repl/agent.d.ts +4 -0
  33. package/dist/repl/agent.d.ts.map +1 -1
  34. package/dist/repl/agent.js +2 -0
  35. package/dist/repl/agent.js.map +1 -1
  36. package/dist/repl/form-widget.d.ts +56 -0
  37. package/dist/repl/form-widget.d.ts.map +1 -0
  38. package/dist/repl/form-widget.js +289 -0
  39. package/dist/repl/form-widget.js.map +1 -0
  40. package/dist/repl/harness-tools.d.ts +3 -1
  41. package/dist/repl/harness-tools.d.ts.map +1 -1
  42. package/dist/repl/harness-tools.js +13 -56
  43. package/dist/repl/harness-tools.js.map +1 -1
  44. package/dist/repl/history.d.ts +2 -1
  45. package/dist/repl/history.d.ts.map +1 -1
  46. package/dist/repl/history.js +34 -8
  47. package/dist/repl/history.js.map +1 -1
  48. package/dist/repl/index.d.ts +14 -2
  49. package/dist/repl/index.d.ts.map +1 -1
  50. package/dist/repl/index.js +6 -0
  51. package/dist/repl/index.js.map +1 -1
  52. package/dist/repl/process-backend.d.ts +2 -2
  53. package/dist/repl/process-backend.d.ts.map +1 -1
  54. package/dist/repl/process-executor.d.ts +11 -2
  55. package/dist/repl/process-executor.d.ts.map +1 -1
  56. package/dist/repl/process-executor.js +67 -18
  57. package/dist/repl/process-executor.js.map +1 -1
  58. package/dist/repl/process-worker.d.ts +35 -4
  59. package/dist/repl/process-worker.d.ts.map +1 -1
  60. package/dist/repl/process-worker.js +78 -38
  61. package/dist/repl/process-worker.js.map +1 -1
  62. package/dist/repl/processes.d.ts +4 -0
  63. package/dist/repl/processes.d.ts.map +1 -1
  64. package/dist/repl/processes.js +20 -0
  65. package/dist/repl/processes.js.map +1 -1
  66. package/dist/repl/render.d.ts +17 -0
  67. package/dist/repl/render.d.ts.map +1 -1
  68. package/dist/repl/render.js +50 -0
  69. package/dist/repl/render.js.map +1 -1
  70. package/dist/repl/session.d.ts +112 -7
  71. package/dist/repl/session.d.ts.map +1 -1
  72. package/dist/repl/session.js +241 -15
  73. package/dist/repl/session.js.map +1 -1
  74. package/dist/repl/tui/form-view.d.ts +17 -0
  75. package/dist/repl/tui/form-view.d.ts.map +1 -0
  76. package/dist/repl/tui/form-view.js +25 -0
  77. package/dist/repl/tui/form-view.js.map +1 -0
  78. package/dist/repl/tui/renderer.d.ts +4 -0
  79. package/dist/repl/tui/renderer.d.ts.map +1 -1
  80. package/dist/repl/tui/renderer.js +12 -0
  81. package/dist/repl/tui/renderer.js.map +1 -1
  82. package/dist/repl/worker-host.d.ts +16 -2
  83. package/dist/repl/worker-host.d.ts.map +1 -1
  84. package/dist/repl/worker-host.js +38 -6
  85. package/dist/repl/worker-host.js.map +1 -1
  86. package/dist/repl/worker-protocol.d.ts +14 -0
  87. package/dist/repl/worker-protocol.d.ts.map +1 -1
  88. package/dist/repl/worker-protocol.js +8 -1
  89. package/dist/repl/worker-protocol.js.map +1 -1
  90. package/dist/runner.d.ts +63 -1
  91. package/dist/runner.d.ts.map +1 -1
  92. package/dist/runner.js +361 -40
  93. package/dist/runner.js.map +1 -1
  94. package/dist/sdk.d.ts.map +1 -1
  95. package/dist/sdk.js +12 -0
  96. package/dist/sdk.js.map +1 -1
  97. package/dist/system/assets/docs/AGENT_PROMPT.md +441 -18
  98. package/dist/system/assets/docs/CHAT_PROMPT.md +28 -0
  99. package/dist/system/assets/docs/DML_AUTHORING.md +1 -1
  100. package/dist/system/assets/docs/DML_FORMS.md +740 -0
  101. package/dist/system/assets/docs/DML_REFERENCE.md +234 -4
  102. package/dist/system/assets/docs/HARNESS_EXAMPLES.md +94 -0
  103. package/dist/system/assets/index.d.ts +2 -2
  104. package/dist/system/assets/index.d.ts.map +1 -1
  105. package/dist/system/assets/index.js +4 -0
  106. package/dist/system/assets/index.js.map +1 -1
  107. package/dist/system/environment.d.ts +6 -0
  108. package/dist/system/environment.d.ts.map +1 -1
  109. package/dist/system/environment.js +5 -4
  110. package/dist/system/environment.js.map +1 -1
  111. package/dist/types.d.ts +35 -1
  112. package/dist/types.d.ts.map +1 -1
  113. package/package.json +9 -7
  114. package/src/prolog-src/deepclause_mi.pl +650 -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
- // Yield streaming events as they arrive
954
- if (this.options.streaming) {
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 repeat it
973
- // The outputs array is kept for memory/history purposes only
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 {
@@ -1595,6 +1686,17 @@ export class DMLRunner {
1595
1686
  if (process.env.DEBUG_RUNNER) {
1596
1687
  console.log('[RUNNER] Tool exec request:', execToolName, execArgs);
1597
1688
  }
1689
+ // The tool policy governs host capabilities, and an `exec/2` inside a
1690
+ // DML tool body is one. The top-level path checks this in
1691
+ // handleExec; without the same check here a tool could reach a
1692
+ // blocked host tool (`ask_user` is exempt inside checkToolPolicy).
1693
+ const policyCheck = checkToolPolicy(execToolName, runContext.toolPolicy ?? null);
1694
+ if (!policyCheck.allowed) {
1695
+ onToolEvent?.(buildToolStartEvent(execToolName, {}));
1696
+ onToolEvent?.(buildToolFailureEvent(execToolName, {}, new Error(policyCheck.reason)));
1697
+ this.postExecResult({ success: false, error: policyCheck.reason });
1698
+ break;
1699
+ }
1598
1700
  // Find and execute the registered tool
1599
1701
  const tool = registeredTools.get(execToolName);
1600
1702
  if (!tool) {
@@ -1831,6 +1933,7 @@ export class DMLRunner {
1831
1933
  // Extract memory
1832
1934
  const memory = this.extractMemoryFromPayload(payload);
1833
1935
  const preparedMemory = memory;
1936
+ const nestedConversational = String(toJsValue(payload?.mode) ?? '') === 'chat';
1834
1937
  // Extract reasoning effort and recipe context for nested loops
1835
1938
  const nestedReasoningEffort = String(toJsValue(payload?.reasoningEffort) ?? 'none');
1836
1939
  const nestedRecipeContext = String(toJsValue(payload?.recipeContext) ?? '');
@@ -1873,11 +1976,13 @@ export class DMLRunner {
1873
1976
  const result = await runAgentLoop({
1874
1977
  taskDescription,
1875
1978
  outputVars,
1979
+ conversational: nestedConversational,
1876
1980
  memory: nestedEffectiveMemory,
1877
1981
  tools: availableTools,
1878
1982
  // Withheld to break a call cycle; declared to the model so a
1879
1983
  // call can be answered with the reason rather than "not found".
1880
1984
  excludedTools: nestedExcludedTools,
1985
+ capabilityTool: this.options.capabilityTool,
1881
1986
  modelOptions: {
1882
1987
  model: this.options.model,
1883
1988
  provider: this.options.provider,
@@ -1977,7 +2082,41 @@ export class DMLRunner {
1977
2082
  }
1978
2083
  break;
1979
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
+ }
1980
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)));
1981
2120
  return { result: { error: content }, outputs };
1982
2121
  }
1983
2122
  default: {
@@ -2063,7 +2202,7 @@ export class DMLRunner {
2063
2202
  /**
2064
2203
  * Post agent loop result back to Prolog
2065
2204
  */
2066
- postAgentResult(result, fullMemory) {
2205
+ postAgentResult(result, fullMemory, error) {
2067
2206
  // Keys need to be quoted because they start with uppercase (Var1, Var2, etc.)
2068
2207
  const varsStr = Object.entries(result.variables)
2069
2208
  .map(([k, v]) => `'${k}': ${this.toPrologTerm(v)}`)
@@ -2071,12 +2210,194 @@ export class DMLRunner {
2071
2210
  // Convert messages to Prolog list format
2072
2211
  const messagesStr = this.serializeMemoryMessages(result.messages);
2073
2212
  const fullMemoryStr = this.serializeMemoryMessages(fullMemory ?? []);
2074
- 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 ?? '')})`);
2075
2214
  }
2076
2215
  /**
2077
2216
  * Post a process primitive result back to Prolog under a named slot.
2078
2217
  * `term` is a raw Prolog term string, so the caller controls encoding.
2079
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
+ }
2080
2401
  postProcessResult(key, term) {
2081
2402
  const atom = /^[a-z][A-Za-z0-9_]*$/.test(key) ? key : `'${key.replace(/'/g, "''")}'`;
2082
2403
  this.query(`deepclause_mi:post_proc_result('${this.sessionId}', ${atom}, ${term})`);