deepclause-sdk 0.2.2 → 0.4.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 (117) hide show
  1. package/README.md +86 -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 +177 -5
  10. package/dist/cli/repl.js.map +1 -1
  11. package/dist/cli/run.d.ts.map +1 -1
  12. package/dist/cli/run.js +4 -0
  13. package/dist/cli/run.js.map +1 -1
  14. package/dist/forms/index.d.ts +3 -0
  15. package/dist/forms/index.d.ts.map +1 -0
  16. package/dist/forms/index.js +3 -0
  17. package/dist/forms/index.js.map +1 -0
  18. package/dist/forms/registry.d.ts +119 -0
  19. package/dist/forms/registry.d.ts.map +1 -0
  20. package/dist/forms/registry.js +256 -0
  21. package/dist/forms/registry.js.map +1 -0
  22. package/dist/forms/spec.d.ts +193 -0
  23. package/dist/forms/spec.d.ts.map +1 -0
  24. package/dist/forms/spec.js +295 -0
  25. package/dist/forms/spec.js.map +1 -0
  26. package/dist/index.d.ts +7 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/prolog/loader.d.ts +18 -0
  31. package/dist/prolog/loader.d.ts.map +1 -1
  32. package/dist/prolog/loader.js +26 -1
  33. package/dist/prolog/loader.js.map +1 -1
  34. package/dist/prolog-src/deepclause_mi.pl +786 -40
  35. package/dist/repl/agent.d.ts +11 -0
  36. package/dist/repl/agent.d.ts.map +1 -1
  37. package/dist/repl/agent.js +23 -9
  38. package/dist/repl/agent.js.map +1 -1
  39. package/dist/repl/form-widget.d.ts +56 -0
  40. package/dist/repl/form-widget.d.ts.map +1 -0
  41. package/dist/repl/form-widget.js +289 -0
  42. package/dist/repl/form-widget.js.map +1 -0
  43. package/dist/repl/harness-tools.d.ts +4 -1
  44. package/dist/repl/harness-tools.d.ts.map +1 -1
  45. package/dist/repl/harness-tools.js +15 -58
  46. package/dist/repl/harness-tools.js.map +1 -1
  47. package/dist/repl/history.d.ts +24 -1
  48. package/dist/repl/history.d.ts.map +1 -1
  49. package/dist/repl/history.js +76 -8
  50. package/dist/repl/history.js.map +1 -1
  51. package/dist/repl/index.d.ts +20 -2
  52. package/dist/repl/index.d.ts.map +1 -1
  53. package/dist/repl/index.js +9 -0
  54. package/dist/repl/index.js.map +1 -1
  55. package/dist/repl/process-backend.d.ts +2 -2
  56. package/dist/repl/process-backend.d.ts.map +1 -1
  57. package/dist/repl/process-executor.d.ts +11 -2
  58. package/dist/repl/process-executor.d.ts.map +1 -1
  59. package/dist/repl/process-executor.js +67 -18
  60. package/dist/repl/process-executor.js.map +1 -1
  61. package/dist/repl/process-worker.d.ts +35 -4
  62. package/dist/repl/process-worker.d.ts.map +1 -1
  63. package/dist/repl/process-worker.js +78 -38
  64. package/dist/repl/process-worker.js.map +1 -1
  65. package/dist/repl/processes.d.ts +4 -0
  66. package/dist/repl/processes.d.ts.map +1 -1
  67. package/dist/repl/processes.js +20 -0
  68. package/dist/repl/processes.js.map +1 -1
  69. package/dist/repl/render.d.ts +17 -0
  70. package/dist/repl/render.d.ts.map +1 -1
  71. package/dist/repl/render.js +60 -0
  72. package/dist/repl/render.js.map +1 -1
  73. package/dist/repl/session.d.ts +143 -7
  74. package/dist/repl/session.d.ts.map +1 -1
  75. package/dist/repl/session.js +317 -20
  76. package/dist/repl/session.js.map +1 -1
  77. package/dist/repl/tui/form-view.d.ts +17 -0
  78. package/dist/repl/tui/form-view.d.ts.map +1 -0
  79. package/dist/repl/tui/form-view.js +25 -0
  80. package/dist/repl/tui/form-view.js.map +1 -0
  81. package/dist/repl/tui/renderer.d.ts +4 -0
  82. package/dist/repl/tui/renderer.d.ts.map +1 -1
  83. package/dist/repl/tui/renderer.js +12 -0
  84. package/dist/repl/tui/renderer.js.map +1 -1
  85. package/dist/repl/worker-host.d.ts +16 -2
  86. package/dist/repl/worker-host.d.ts.map +1 -1
  87. package/dist/repl/worker-host.js +38 -6
  88. package/dist/repl/worker-host.js.map +1 -1
  89. package/dist/repl/worker-protocol.d.ts +14 -0
  90. package/dist/repl/worker-protocol.d.ts.map +1 -1
  91. package/dist/repl/worker-protocol.js +8 -1
  92. package/dist/repl/worker-protocol.js.map +1 -1
  93. package/dist/runner.d.ts +69 -1
  94. package/dist/runner.d.ts.map +1 -1
  95. package/dist/runner.js +381 -44
  96. package/dist/runner.js.map +1 -1
  97. package/dist/sdk.d.ts.map +1 -1
  98. package/dist/sdk.js +12 -0
  99. package/dist/sdk.js.map +1 -1
  100. package/dist/system/assets/docs/AGENT_PROMPT.md +452 -18
  101. package/dist/system/assets/docs/CHAT_PROMPT.md +28 -0
  102. package/dist/system/assets/docs/DML_AUTHORING.md +8 -4
  103. package/dist/system/assets/docs/DML_FORMS.md +740 -0
  104. package/dist/system/assets/docs/DML_REFERENCE.md +239 -4
  105. package/dist/system/assets/docs/HARNESS_EXAMPLES.md +94 -0
  106. package/dist/system/assets/index.d.ts +2 -2
  107. package/dist/system/assets/index.d.ts.map +1 -1
  108. package/dist/system/assets/index.js +4 -0
  109. package/dist/system/assets/index.js.map +1 -1
  110. package/dist/system/environment.d.ts +6 -0
  111. package/dist/system/environment.d.ts.map +1 -1
  112. package/dist/system/environment.js +5 -4
  113. package/dist/system/environment.js.map +1 -1
  114. package/dist/types.d.ts +48 -1
  115. package/dist/types.d.ts.map +1 -1
  116. package/package.json +4 -2
  117. package/src/prolog-src/deepclause_mi.pl +786 -40
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.
@@ -368,6 +413,14 @@ export class DMLRunner {
368
413
  };
369
414
  break;
370
415
  }
416
+ case 'html': {
417
+ const payload = (step.payload ?? {});
418
+ yield {
419
+ type: 'html',
420
+ html: { path: typeof payload.path === 'string' ? payload.path : '' },
421
+ };
422
+ break;
423
+ }
371
424
  case 'log':
372
425
  if (step.content) {
373
426
  yield { type: 'log', content: step.content };
@@ -376,6 +429,21 @@ export class DMLRunner {
376
429
  case 'answer':
377
430
  yield { type: 'answer', content: step.content };
378
431
  break;
432
+ case 'suspend': {
433
+ const payload = (step.payload ?? {});
434
+ const continuation = String(toJsValue(payload.continuation) ?? '');
435
+ // The memory arrives as Prolog dicts; serialize it as Prolog text, the
436
+ // form the MI can load back. `String(list)` produced
437
+ // "[object Object],[object Object]", which then failed to load — so a
438
+ // suspended turn used to lose the whole conversation.
439
+ // Bracketed: the record is the *term text* a resume loads back
440
+ // (`repl_set_memory/3`), so it must be a list, not a bare sequence.
441
+ const memory = `[${this.serializeMemoryMessages(this.extractMemoryFromPayload(payload))}]`;
442
+ if (options.onSuspend)
443
+ await options.onSuspend({ continuation, memory });
444
+ yield { type: 'suspend', suspend: { continuation, memory } };
445
+ break;
446
+ }
379
447
  case 'solution': {
380
448
  const payload = (step.payload ?? {});
381
449
  yield {
@@ -433,6 +501,37 @@ export class DMLRunner {
433
501
  }
434
502
  break;
435
503
  }
504
+ case 'request_form': {
505
+ // A form operation. The host owns the registry; the result is posted
506
+ // back so Prolog/MI keeps the encoding (docs/DML_FORMS.md §7).
507
+ const payload = (step.payload ?? {});
508
+ const event = this.handleFormRequest(payload, options.forms);
509
+ if (event) {
510
+ yield event;
511
+ // `update` re-presents too: a rejected submit (or a model editing the
512
+ // form) must reach the person filling it in, with the errors on it.
513
+ if ((event.form?.op === 'open' || event.form?.op === 'update') && options.onForm) {
514
+ await options.onForm(event.form);
515
+ }
516
+ }
517
+ break;
518
+ }
519
+ case 'wait_form': {
520
+ const payload = (step.payload ?? {});
521
+ const formId = String(toJsValue(payload.formId) ?? '');
522
+ const since = Number(toJsValue(payload.since) ?? 0);
523
+ const timeoutMs = Number(toJsValue(payload.timeout) ?? 0);
524
+ this.postFormResult(await this.handleFormWait(formId, since, timeoutMs, options.forms, options.signal));
525
+ break;
526
+ }
527
+ case 'wait_next': {
528
+ const accept = String(toJsValue(step.payload?.accept) ?? 'any');
529
+ if (this.armChatInput(options))
530
+ yield { type: 'input_required', prompt: '' };
531
+ const item = await this.waitForChatItem(accept, options);
532
+ this.postNextResult(item.term);
533
+ break;
534
+ }
436
535
  case 'request_sample_token':
437
536
  yield* this.handleSampleToken(step.payload, options);
438
537
  break;
@@ -715,6 +814,9 @@ export class DMLRunner {
715
814
  options, _frame) {
716
815
  const rawPayload = payload;
717
816
  const taskDescription = String(toJsValue(rawPayload.taskDescription) ?? '');
817
+ // `chat` is a conversational turn: tools, no finish, ends when the model
818
+ // stops calling tools (see agent.ts `conversational`).
819
+ const conversational = String(toJsValue(rawPayload.mode) ?? '') === 'chat';
718
820
  const stepId = `step_${this.stepCounter++}`;
719
821
  yield { type: 'task_activity', taskState: 'started', taskDescription, taskId: stepId };
720
822
  let stepSucceeded = null;
@@ -875,16 +977,40 @@ export class DMLRunner {
875
977
  toolPolicy: options.toolPolicy,
876
978
  onModelCall: options.onModelCall,
877
979
  onProcessRequest: options.onProcessRequest,
980
+ forms: options.forms,
981
+ onForm: options.onForm,
878
982
  }, withheld, emitToolCall, emitToolEvent);
879
983
  }, withheld);
880
984
  // Run agent loop with streaming support
881
985
  const resultPromise = runAgentLoop({
882
986
  taskDescription,
883
987
  outputVars,
988
+ conversational,
989
+ // A chat turn does not end while a form it opened is still unanswered: the
990
+ // model may have rendered the form and then replied without waiting (it
991
+ // does not have to know the two-call protocol). Wait for the submission and
992
+ // hand it back as the next turn's input, so nothing is lost.
993
+ onIdleTurn: async () => {
994
+ if (!conversational || !options.forms)
995
+ return null;
996
+ const awaiting = options.forms.awaitingForms();
997
+ if (awaiting.length === 0)
998
+ return null;
999
+ const waits = await Promise.all(awaiting.map((record) => options.forms.wait(record.formId, -1, { signal: options.signal })));
1000
+ return waits
1001
+ .map((change) => {
1002
+ const values = JSON.stringify(change.values ?? {});
1003
+ return change.action === 'submit'
1004
+ ? `The person submitted the form ${change.formId}: ${values}`
1005
+ : `The person dismissed the form ${change.formId} (action: ${change.action}).`;
1006
+ })
1007
+ .join('\n');
1008
+ },
884
1009
  memory: effectiveMemory,
885
1010
  tools: availableTools,
886
1011
  workspacePath: options.workspacePath,
887
1012
  environment: this.options.environment,
1013
+ capabilityTool: this.options.capabilityTool,
888
1014
  resolveMedia: (refs) => this.readMediaRefs(options.workspacePath, refs),
889
1015
  modelOptions: {
890
1016
  model: scopedModel?.id ?? this.options.model,
@@ -950,8 +1076,12 @@ export class DMLRunner {
950
1076
  signal: options.signal,
951
1077
  llmBackend: this.options.llmBackend,
952
1078
  });
953
- // Yield streaming events as they arrive
954
- if (this.options.streaming) {
1079
+ // Drain host events as they arrive. This must run even when not streaming:
1080
+ // a tool can block on a host request — a form wait, an `ask_user` — and that
1081
+ // request must reach the consumer *before* the tool returns, or the run
1082
+ // deadlocks. Non-streaming queues no stream/reasoning chunks, so yielding
1083
+ // everything here is equivalent.
1084
+ {
955
1085
  while (true) {
956
1086
  // Process any queued events
957
1087
  while (streamQueue.length > 0) {
@@ -959,18 +1089,35 @@ export class DMLRunner {
959
1089
  }
960
1090
  // Check if result is ready
961
1091
  const raceResult = await Promise.race([
962
- resultPromise.then(r => ({ type: 'done', result: r })),
1092
+ resultPromise.then(r => ({ type: 'done', result: r }), (error) => ({ type: 'failed', error })),
963
1093
  new Promise(resolve => {
964
1094
  streamResolve = () => resolve({ type: 'stream' });
965
1095
  }),
966
1096
  ]);
1097
+ if (raceResult.type === 'failed') {
1098
+ const message = raceResult.error instanceof Error
1099
+ ? raceResult.error.message
1100
+ : String(raceResult.error);
1101
+ if (!conversational)
1102
+ throw raceResult.error;
1103
+ // A conversation must survive a failed turn: report it and keep the
1104
+ // memory as it was, so the person can simply type again. Failing the
1105
+ // turn here would end `loop :- input(A), chat(A), loop.`
1106
+ this.postAgentResult({ success: false, outputs: [], variables: {}, messages: [] }, preparedMemory.filter((entry) => entry.role === 'system'), message);
1107
+ return;
1108
+ }
967
1109
  if (raceResult.type === 'done') {
968
1110
  // Drain remaining queue
969
1111
  while (streamQueue.length > 0) {
970
1112
  yield streamQueue.shift();
971
1113
  }
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
1114
+ // In streaming mode, output was already shown via onStream - don't
1115
+ // repeat it. The outputs array is kept for memory/history purposes.
1116
+ if (!this.options.streaming) {
1117
+ for (const output of raceResult.result.outputs) {
1118
+ yield { type: 'output', content: output };
1119
+ }
1120
+ }
974
1121
  const finalizedMemory = [
975
1122
  ...preparedMemory.filter((message) => message.role === 'system'),
976
1123
  ...raceResult.result.messages,
@@ -988,39 +1135,6 @@ export class DMLRunner {
988
1135
  // Otherwise continue loop to yield queued stream events
989
1136
  }
990
1137
  }
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
1138
  }
1025
1139
  finally {
1026
1140
  yield {
@@ -1842,6 +1956,10 @@ export class DMLRunner {
1842
1956
  // Extract memory
1843
1957
  const memory = this.extractMemoryFromPayload(payload);
1844
1958
  const preparedMemory = memory;
1959
+ const nestedConversational = String(toJsValue(payload?.mode) ?? '') === 'chat';
1960
+ // Nested tasks/prompts carry the same with_model scope as top-level
1961
+ // ones; resolve it through the session resolver.
1962
+ const nestedScopedModel = await this.resolveScope(payload?.modelSpec);
1845
1963
  // Extract reasoning effort and recipe context for nested loops
1846
1964
  const nestedReasoningEffort = String(toJsValue(payload?.reasoningEffort) ?? 'none');
1847
1965
  const nestedRecipeContext = String(toJsValue(payload?.recipeContext) ?? '');
@@ -1884,18 +2002,21 @@ export class DMLRunner {
1884
2002
  const result = await runAgentLoop({
1885
2003
  taskDescription,
1886
2004
  outputVars,
2005
+ conversational: nestedConversational,
1887
2006
  memory: nestedEffectiveMemory,
1888
2007
  tools: availableTools,
1889
2008
  // Withheld to break a call cycle; declared to the model so a
1890
2009
  // call can be answered with the reason rather than "not found".
1891
2010
  excludedTools: nestedExcludedTools,
2011
+ capabilityTool: this.options.capabilityTool,
1892
2012
  modelOptions: {
1893
- model: this.options.model,
1894
- provider: this.options.provider,
1895
- temperature: this.options.temperature,
1896
- maxOutputTokens: this.options.maxTokens,
2013
+ model: nestedScopedModel?.id ?? this.options.model,
2014
+ provider: nestedScopedModel?.provider ?? this.options.provider,
2015
+ temperature: nestedScopedModel?.temperature ?? this.options.temperature,
2016
+ maxOutputTokens: nestedScopedModel?.maxTokens ?? this.options.maxTokens,
1897
2017
  baseUrl: this.options.baseUrl,
1898
2018
  providerOptions: nestedProviderOptions,
2019
+ target: nestedScopedModel,
1899
2020
  },
1900
2021
  streaming: this.options.streaming,
1901
2022
  debug: this.options.debug,
@@ -1988,7 +2109,41 @@ export class DMLRunner {
1988
2109
  }
1989
2110
  break;
1990
2111
  }
2112
+ case 'request_form': {
2113
+ // A DML tool reached a form primitive. Resolve it through the same
2114
+ // host registry as the enclosing run (docs/DML_FORMS.md §4).
2115
+ const formPayload = (payload ?? {});
2116
+ const event = this.handleFormRequest(formPayload, runContext.forms);
2117
+ if (event) {
2118
+ onToolEvent?.(event);
2119
+ // The host interaction must run here too: a form opened by a tool
2120
+ // (`render_form`) is the common case, and without this nothing
2121
+ // prompts and the run just blocks on the wait.
2122
+ if ((event.form?.op === 'open' || event.form?.op === 'update') && runContext.onForm) {
2123
+ await runContext.onForm(event.form);
2124
+ }
2125
+ }
2126
+ break;
2127
+ }
2128
+ case 'wait_form': {
2129
+ const formPayload = (payload ?? {});
2130
+ const formId = String(toJsValue(formPayload.formId) ?? '');
2131
+ const since = Number(toJsValue(formPayload.since) ?? 0);
2132
+ const timeoutMs = Number(toJsValue(formPayload.timeout) ?? 0);
2133
+ this.postFormResult(await this.handleFormWait(formId, since, timeoutMs, runContext.forms, runContext.signal));
2134
+ break;
2135
+ }
2136
+ case 'wait_next': {
2137
+ // A conversation turn inside a tool (rare, but the same contract).
2138
+ const accept = String(toJsValue(payload?.accept) ?? 'any');
2139
+ const item = await this.waitForChatItem(accept, runContext);
2140
+ this.postNextResult(item.term);
2141
+ break;
2142
+ }
1991
2143
  case 'error': {
2144
+ // A tool-engine error (a thrown body, or a tool that returned no
2145
+ // result). Report it as a failure so it is not mistaken for success.
2146
+ onToolEvent?.(buildToolFailureEvent(toolName, {}, new Error(content)));
1992
2147
  return { result: { error: content }, outputs };
1993
2148
  }
1994
2149
  default: {
@@ -2074,7 +2229,7 @@ export class DMLRunner {
2074
2229
  /**
2075
2230
  * Post agent loop result back to Prolog
2076
2231
  */
2077
- postAgentResult(result, fullMemory) {
2232
+ postAgentResult(result, fullMemory, error) {
2078
2233
  // Keys need to be quoted because they start with uppercase (Var1, Var2, etc.)
2079
2234
  const varsStr = Object.entries(result.variables)
2080
2235
  .map(([k, v]) => `'${k}': ${this.toPrologTerm(v)}`)
@@ -2082,12 +2237,194 @@ export class DMLRunner {
2082
2237
  // Convert messages to Prolog list format
2083
2238
  const messagesStr = this.serializeMemoryMessages(result.messages);
2084
2239
  const fullMemoryStr = this.serializeMemoryMessages(fullMemory ?? []);
2085
- this.query(`deepclause_mi:post_agent_result('${this.sessionId}', ${result.success}, vars{${varsStr}}, [${messagesStr}], [${fullMemoryStr}])`);
2240
+ this.query(`deepclause_mi:post_agent_result('${this.sessionId}', ${result.success}, vars{${varsStr}}, [${messagesStr}], [${fullMemoryStr}], ${this.toPrologTerm(error ?? '')})`);
2086
2241
  }
2087
2242
  /**
2088
2243
  * Post a process primitive result back to Prolog under a named slot.
2089
2244
  * `term` is a raw Prolog term string, so the caller controls encoding.
2090
2245
  */
2246
+ /**
2247
+ * Apply a form operation and return the `form` event to emit, if any.
2248
+ * The registry owns state; the result is posted back to Prolog as a term so
2249
+ * the MI keeps the encoding, like `request_proc` (docs/DML_FORMS.md §7).
2250
+ */
2251
+ handleFormRequest(payload, forms) {
2252
+ if (!forms) {
2253
+ this.postFormResult('error(no_form_host)');
2254
+ return undefined;
2255
+ }
2256
+ const op = String(toJsValue(payload.op) ?? '');
2257
+ if (op === 'open') {
2258
+ // A caller (a harness, or a model that designed the form) may leave out the
2259
+ // boilerplate: `version`, and each field's `type`.
2260
+ const spec = normalizeSpec(toJsValue(payload.spec));
2261
+ const opened = forms.open(spec);
2262
+ this.postFormResult(`ok(${this.toPrologTerm({ formId: opened.formId, revision: opened.revision })})`);
2263
+ return {
2264
+ type: 'form',
2265
+ form: { op: 'open', formId: opened.formId, revision: opened.revision, spec, values: opened.values, issues: [] },
2266
+ };
2267
+ }
2268
+ const formId = String(toJsValue(payload.formId) ?? '');
2269
+ if (op === 'update') {
2270
+ const patch = (toJsValue(payload.patch) ?? {});
2271
+ forms.update(formId, patch);
2272
+ this.postFormResult('ok');
2273
+ const record = forms.get(formId);
2274
+ // `spec` travels with every form event: a renderer that only gets an update
2275
+ // still needs the fields to draw (and to show the issues against them).
2276
+ return record
2277
+ ? { type: 'form', form: { op: 'update', formId, revision: record.revision, spec: record.spec, patch, values: record.values, issues: record.issues } }
2278
+ : undefined;
2279
+ }
2280
+ if (op === 'errors') {
2281
+ const issues = toFormIssues(payload.issues);
2282
+ forms.setIssues(formId, issues);
2283
+ this.postFormResult('ok');
2284
+ const record = forms.get(formId);
2285
+ return record
2286
+ ? { type: 'form', form: { op: 'update', formId, revision: record.revision, spec: record.spec, values: record.values, issues } }
2287
+ : undefined;
2288
+ }
2289
+ if (op === 'close') {
2290
+ forms.close(formId, 'submitted');
2291
+ this.postFormResult('ok');
2292
+ const record = forms.get(formId);
2293
+ return { type: 'form', form: { op: 'close', formId, revision: record?.revision ?? 0, state: 'submitted' } };
2294
+ }
2295
+ this.postFormResult(`error(unknown_form_op(${op}))`);
2296
+ return undefined;
2297
+ }
2298
+ /** Await a form change and return the result term to post to Prolog. */
2299
+ /**
2300
+ * Wait for a UI change, enforcing the rules the host owns.
2301
+ *
2302
+ * A submit the rules reject comes back as `invalid` with the errors. The DML
2303
+ * side (see `mi_call(await_form(...))`) turns that into "show the errors, wait
2304
+ * again", so neither a harness nor a model ever acts on a refused submit — and
2305
+ * neither has to write the re-show loop.
2306
+ */
2307
+ async handleFormWait(formId, since, timeoutMs, forms, signal) {
2308
+ if (!forms)
2309
+ return 'error(no_form_host)';
2310
+ try {
2311
+ const change = await forms.wait(formId, since, {
2312
+ timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
2313
+ signal,
2314
+ });
2315
+ // Layer 1: the spec's own constraints (required, enum, min/max, pattern).
2316
+ // Layer 2: the harness's `form_validate/2` domain rules. Both block.
2317
+ const record = forms.get(formId);
2318
+ if (record && shouldValidate(record.spec, change.action)) {
2319
+ const errors = [
2320
+ ...validateValues(record.spec, change.values),
2321
+ ...this.validateFormValues(change.values),
2322
+ ];
2323
+ if (errors.length > 0) {
2324
+ return `ok(${this.toPrologTerm({ ...change, action: 'invalid', errors })})`;
2325
+ }
2326
+ }
2327
+ return `ok(${this.toPrologTerm(change)})`;
2328
+ }
2329
+ catch (error) {
2330
+ const message = error instanceof Error ? error.message : String(error);
2331
+ return `error(${JSON.stringify(message)})`;
2332
+ }
2333
+ }
2334
+ /** Run the harness's `form_validate/2` and return the issue dicts it produced. */
2335
+ validateFormValues(values) {
2336
+ const result = this.query(`deepclause_mi:form_validate_values('${this.sessionId}', ${this.toPrologTerm(values)}, Issues)`);
2337
+ return toFormIssues(result?.Issues);
2338
+ }
2339
+ // ---- Conversation inbox (docs/DML_CONVERSATION.md §5) ------------------
2340
+ /** Start the host's input request, unless one is already outstanding. */
2341
+ armChatInput(options) {
2342
+ if (this.chatInputPending)
2343
+ return false;
2344
+ if (!options.onInputRequired)
2345
+ return false;
2346
+ this.chatInputPending = options
2347
+ .onInputRequired('')
2348
+ .then((text) => {
2349
+ this.chatInputPending = null;
2350
+ // No answer means nobody is there: close the inbox, which ends a
2351
+ // conversation loop instead of spinning on empty turns.
2352
+ this.pushChatItem(text === undefined || text === null
2353
+ ? { kind: 'closed', term: 'closed' }
2354
+ : { kind: 'user', term: `user(${this.toPrologTerm(text)})` });
2355
+ })
2356
+ .catch(() => {
2357
+ this.chatInputPending = null;
2358
+ this.pushChatItem({ kind: 'closed', term: 'closed' });
2359
+ });
2360
+ return true;
2361
+ }
2362
+ pushChatItem(item) {
2363
+ this.chatInbox.push(item);
2364
+ const wake = this.chatInboxWake;
2365
+ this.chatInboxWake = null;
2366
+ wake?.();
2367
+ }
2368
+ /**
2369
+ * The next item the caller accepts; `user` skips (keeps) worker messages.
2370
+ * A closed inbox is the last resort: a message that arrived is worth more than
2371
+ * "nobody is typing", so it is handed out only when nothing else is queued.
2372
+ */
2373
+ takeChatItem(accept) {
2374
+ const open = this.chatInbox.findIndex((item) => item.kind !== 'closed' && (accept === 'any' || item.kind === 'user'));
2375
+ if (open >= 0)
2376
+ return this.chatInbox.splice(open, 1)[0];
2377
+ const closed = this.chatInbox.findIndex((item) => item.kind === 'closed');
2378
+ return closed >= 0 ? this.chatInbox.splice(closed, 1)[0] : undefined;
2379
+ }
2380
+ /**
2381
+ * Wait for the next inbox item: the person's message, or one a worker sent this
2382
+ * session. The mailbox is polled with a short timeout rather than held open, so
2383
+ * a run that ends mid-wait leaves no receiver behind.
2384
+ */
2385
+ async waitForChatItem(accept, options) {
2386
+ for (;;) {
2387
+ const ready = this.takeChatItem(accept);
2388
+ if (ready && ready.kind !== 'closed')
2389
+ return ready;
2390
+ if (ready?.kind === 'closed') {
2391
+ // The person is gone, but a worker's message may still be in flight: give
2392
+ // the mailbox one last chance so closing the input does not swallow it.
2393
+ await this.pollChatMessage(options);
2394
+ return this.takeChatItem(accept) ?? ready;
2395
+ }
2396
+ const wake = new Promise((resolve) => {
2397
+ this.chatInboxWake = resolve;
2398
+ });
2399
+ await Promise.race([wake, this.pollChatMessage(options)]);
2400
+ }
2401
+ }
2402
+ /** One mailbox poll, so the inbox sees worker messages as they arrive. */
2403
+ async pollChatMessage(options) {
2404
+ if (!options.onProcessRequest) {
2405
+ await new Promise((resolve) => setTimeout(resolve, 200));
2406
+ return;
2407
+ }
2408
+ try {
2409
+ const result = await options.onProcessRequest({ op: 'receive', timeout: 200 }, options.signal);
2410
+ if (result.term.startsWith('ok(')) {
2411
+ // `ok(<term>)` — the message a worker sent to this session.
2412
+ this.pushChatItem({ kind: 'message', term: `message(${result.term.slice(3, -1)})` });
2413
+ }
2414
+ }
2415
+ catch {
2416
+ // No process host in this run: the person is the only source.
2417
+ await new Promise((resolve) => setTimeout(resolve, 200));
2418
+ }
2419
+ }
2420
+ /** Post the conversation inbox item back to Prolog under its slot. */
2421
+ postNextResult(term) {
2422
+ this.query(`deepclause_mi:post_next_result('${this.sessionId}', ${term})`);
2423
+ }
2424
+ /** Post a form primitive result back to Prolog under the form slot. */
2425
+ postFormResult(term) {
2426
+ this.query(`deepclause_mi:post_form_result('${this.sessionId}', ${term})`);
2427
+ }
2091
2428
  postProcessResult(key, term) {
2092
2429
  const atom = /^[a-z][A-Za-z0-9_]*$/.test(key) ? key : `'${key.replace(/'/g, "''")}'`;
2093
2430
  this.query(`deepclause_mi:post_proc_result('${this.sessionId}', ${atom}, ${term})`);