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.
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 +145 -4
  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 +619 -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 +105 -7
  71. package/dist/repl/session.d.ts.map +1 -1
  72. package/dist/repl/session.js +219 -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 +350 -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 +387 -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 +180 -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 +4 -2
  114. package/src/prolog-src/deepclause_mi.pl +619 -28
@@ -106,7 +106,13 @@
106
106
  create_tool_engine/4,
107
107
  step_tool_engine/4,
108
108
  destroy_tool_engine/1,
109
- post_tool_agent_result/5
109
+ post_tool_agent_result/5,
110
+ % Forms (docs/DML_FORMS.md)
111
+ post_form_result/2,
112
+ post_next_result/2,
113
+ form_validate_values/3,
114
+ form_advisory_values/3,
115
+ repl_normalize_message/2
110
116
  ]).
111
117
 
112
118
  :- use_module(deepclause_strings).
@@ -118,6 +124,7 @@
118
124
  %% `proc_request_payload/2` sits between the process-primitive clauses of
119
125
  %% `process_engine_result/4`, which is intentional.
120
126
  :- discontiguous process_engine_result/4.
127
+ :- discontiguous process_directive/2.
121
128
 
122
129
  %% Dynamic predicates for session state (engine management only, not memory!)
123
130
  :- dynamic session_engine/2. % session_engine(SessionId, Engine) - legacy default engine handle
@@ -155,6 +162,9 @@
155
162
  :- dynamic session_proc_result/3. % session_proc_result(SessionId, Key, Value) - process primitive results
156
163
  :- dynamic session_user_input/2. % session_user_input(SessionId, Input) - for ask_user tool
157
164
  :- dynamic session_input_cancelled/1. % session_input_cancelled(SessionId) - input/2 should fail (non-interactive)
165
+ :- dynamic session_form_result/2. % session_form_result(SessionId, Result) - forms primitive results
166
+ :- dynamic session_builtin_tool/2. % session_builtin_tool(SessionId, ToolName) - runtime-installed tools
167
+ :- dynamic session_next_result/2. % session_next_result(SessionId, Item) - conversation inbox item
158
168
 
159
169
  %% ============================================================
160
170
  %% DML Parsing
@@ -201,7 +211,7 @@ parse_dml_string(Code, SessionId) :-
201
211
  parse_clauses(Stream, SessionId) :-
202
212
  read_term(Stream, Term, [module(SessionId), variable_names(Bindings)]),
203
213
  ( Term == end_of_file
204
- -> true
214
+ -> install_builtin_tools(SessionId)
205
215
  ; preprocess_loaded_term(Term, Bindings, PreparedTerms),
206
216
  forall(member(PreparedTerm, PreparedTerms), process_clause(PreparedTerm, SessionId)),
207
217
  parse_clauses(Stream, SessionId)
@@ -573,6 +583,19 @@ process_clause((:- Directive), SessionId) :-
573
583
  !,
574
584
  process_directive(Directive, SessionId).
575
585
 
586
+ %% Handle tool/3: description plus per-input types.
587
+ %% tool(Head, Description, [object, number]) :- Body.
588
+ %% The leading args are inputs (typed from the list); the last arg is the output.
589
+ process_clause((tool(ToolHead, Description, Types) :- Body), SessionId) :-
590
+ !,
591
+ (catch(expand_dict_dots(Body, ExpandedBody), _, fail) -> true ; ExpandedBody = Body),
592
+ extract_tool_schema(ToolHead, Description, Types, ToolName, Schema),
593
+ assertz(session_user_tools(SessionId, ToolName)),
594
+ assertz(session_user_tool_schema(SessionId, ToolName, Schema)),
595
+ format_tool_source(ToolHead, Description, Types, Body, SourceCode),
596
+ assertz(SessionId:tool_source(ToolName, SourceCode)),
597
+ assertz(SessionId:(tool(ToolHead) :- ExpandedBody)).
598
+
576
599
  %% Handle tool/2 with description: tool(Head, Description) :- Body
577
600
  process_clause((tool(ToolHead, Description) :- Body), SessionId) :-
578
601
  !,
@@ -693,9 +716,17 @@ expand_dict_expr(Expr, Expr).
693
716
 
694
717
  %% extract_tool_schema(+ToolHead, +Description, -ToolName, -Schema)
695
718
  extract_tool_schema(ToolHead, Description, ToolName, Schema) :-
719
+ extract_tool_schema(ToolHead, Description, [], ToolName, Schema).
720
+
721
+ %% extract_tool_schema(+Head, +Description, +Types, -ToolName, -Schema)
722
+ %% `Types` is an optional list giving the type of each input (the leading args,
723
+ %% in order); a missing entry defaults to `string`. This is what lets a tool take
724
+ %% an object, an array or a number rather than only a string.
725
+ extract_tool_schema(ToolHead, Description, Types, ToolName, Schema) :-
696
726
  ToolHead =.. [ToolName|Args],
697
727
  length(Args, Arity),
698
- extract_params(Args, 1, Arity, Inputs, Outputs),
728
+ tool_type_warning(ToolName, Arity, Types),
729
+ extract_params(Args, 1, Arity, Types, Inputs, Outputs),
699
730
  (Description == none -> Desc = "" ; Desc = Description),
700
731
  Schema = schema{
701
732
  name: ToolName,
@@ -704,19 +735,45 @@ extract_tool_schema(ToolHead, Description, ToolName, Schema) :-
704
735
  outputs: Outputs
705
736
  }.
706
737
 
707
- %% extract_params(+Args, +Index, +Arity, -Inputs, -Outputs)
708
- extract_params([], _, _, [], []) :- !.
709
- extract_params([_Arg|Rest], Index, Arity, Inputs, Outputs) :-
738
+ %% extract_params(+Args, +Index, +Arity, +Types, -Inputs, -Outputs)
739
+ %% The last argument is the output; the rest are inputs, typed from `Types`.
740
+ extract_params([], _, _, _, [], []) :- !.
741
+ extract_params([_Arg|Rest], Index, Arity, Types, Inputs, Outputs) :-
710
742
  format(atom(Name), 'arg~w', [Index]),
711
- Type = string,
712
743
  NextIndex is Index + 1,
713
- extract_params(Rest, NextIndex, Arity, RestInputs, RestOutputs),
714
- Param = param{name: Name, type: Type},
744
+ extract_params(Rest, NextIndex, Arity, Types, RestInputs, RestOutputs),
715
745
  ( Index == Arity
746
+ -> IsOutput = true, Type = string
747
+ ; IsOutput = false, input_type(Index, Types, Type)
748
+ ),
749
+ Param = param{name: Name, type: Type},
750
+ ( IsOutput == true
716
751
  -> Inputs = RestInputs, Outputs = [Param|RestOutputs]
717
752
  ; Inputs = [Param|RestInputs], Outputs = RestOutputs
718
753
  ).
719
754
 
755
+ %% input_type(+Index, +Types, -Type): the Index-th input's declared type.
756
+ input_type(1, [T|_], T) :- !.
757
+ input_type(N, [_|Rest], T) :- N > 1, N1 is N - 1, input_type(N1, Rest, T).
758
+ input_type(_, [], string).
759
+
760
+ %% tool_type_warning(+Name, +Arity, +Types)
761
+ %% The last argument of a tool head is the output, so a type list should label
762
+ %% the other (Arity - 1) inputs. When it does not, the author has almost
763
+ %% certainly written a tool whose second input was taken for its output — the
764
+ %% `tool(fs_write(Path, Content), …)` shape, which silently writes nothing.
765
+ tool_type_warning(ToolName, Arity, Types) :-
766
+ is_list(Types),
767
+ Types \== [],
768
+ length(Types, Declared),
769
+ Inputs is Arity - 1,
770
+ Declared =\= Inputs,
771
+ !,
772
+ format(user_error,
773
+ 'Warning: tool ~w/~w declares ~w input type(s) but has ~w input argument(s). The last argument of a tool head is the output; a tool with another input needs a result argument (e.g. fs_write(Path, Content, ok)).~n',
774
+ [ToolName, Arity, Declared, Inputs]).
775
+ tool_type_warning(_, _, _).
776
+
720
777
  module_reference_string(Module, ModuleStr) :-
721
778
  atom(Module),
722
779
  !,
@@ -802,6 +859,113 @@ process_directive(repl_delete(Name/Arity), SessionId) :-
802
859
  Arity >= 0,
803
860
  catch(abolish(SessionId:Name/Arity), _, true).
804
861
 
862
+ %% ============================================================
863
+ %% Built-in tools (docs/DML_FORMS.md)
864
+ %% ============================================================
865
+ %%
866
+ %% These `tool/2` wrappers are installed into every session, so a harness can
867
+ %% hand the model the form tools without declaring anything. They are ordinary
868
+ %% `tool/N` clauses: assert them through `process_clause/2` (never into a
869
+ %% harness's own clause set) and `task/N` offers them, `with_tools/2` scopes
870
+ %% them, and an export carries the source so a standalone run works.
871
+ %%
872
+ %% The gate that used to guard this — `:- capability(forms).` — was removed: it
873
+ %% was file-scoped (processed at load), so it was invisible to a module built
874
+ %% through the edit tools, and a harness could not discover it, because a gated
875
+ %% primitive is a handler and `current_predicate/1` reports `no` for handlers.
876
+
877
+ install_builtin_tools(SessionId) :-
878
+ % The marker keeps these out of the harness's own surface — `listing`, the
879
+ % manifest's predicates, the export, and the end_turn probes — while they
880
+ % stay in `session_user_tool_schema/3`, so `task/N` and `with_tools/2` offer
881
+ % them. They are built in, not authored.
882
+ forall(builtin_tool_name(Name),
883
+ ( session_builtin_tool(SessionId, Name)
884
+ -> true
885
+ ; assertz(session_builtin_tool(SessionId, Name))
886
+ )),
887
+ % Idempotent: `parse_clauses/2` runs on every load, and a duplicate schema
888
+ % would show the model the same tool twice.
889
+ ( session_user_tool_schema(SessionId, render_form, _)
890
+ -> true
891
+ ; forall(builtin_tool_clause(Clause), process_clause(Clause, SessionId))
892
+ ).
893
+
894
+ builtin_tool_name(render_form).
895
+ builtin_tool_name(update_form).
896
+ builtin_tool_name(set_form_issues).
897
+ builtin_tool_name(wait_for_form_change).
898
+ builtin_tool_name(validate_form).
899
+ builtin_tool_name(advisories).
900
+ builtin_tool_name(close_form).
901
+
902
+ builtin_tool_clause(
903
+ (tool(render_form(Spec, FormId),
904
+ "Open (or replace) a form. `Spec` is the form spec (version, title, fields, actions). Returns the form id.",
905
+ [object])
906
+ :- form(Spec, FormId))).
907
+ builtin_tool_clause(
908
+ (tool(update_form(FormId, Patch, Result),
909
+ "Update form values with a JSON merge patch.",
910
+ [string, object])
911
+ :- form_update(FormId, Patch), Result = ok)).
912
+ builtin_tool_clause(
913
+ (tool(set_form_issues(FormId, Issues, Result),
914
+ "Attach per-field errors/warnings. Issues is a list of {path, level, message}.",
915
+ [string, array])
916
+ :- form_errors(FormId, Issues), Result = ok)).
917
+ builtin_tool_clause(
918
+ (tool(wait_for_form_change(FormId, Change),
919
+ "Wait for the user to act on a form. Change is a dict with action, values, revision and changed.",
920
+ [string])
921
+ :- await_form(FormId, Change))).
922
+ builtin_tool_clause(
923
+ (tool(validate_form(Values, Result),
924
+ "Hard-validate values without submitting. Result is a list of issues.",
925
+ [object])
926
+ :- get_session_id(SessionId),
927
+ form_validate_values(SessionId, Values, Result))).
928
+ builtin_tool_clause(
929
+ (tool(advisories(Values, Result),
930
+ "Advisory check of values (never blocks). Result is a list of issues.",
931
+ [object])
932
+ :- get_session_id(SessionId),
933
+ form_advisory_values(SessionId, Values, Result))).
934
+ builtin_tool_clause(
935
+ (tool(close_form(FormId, Result), "Close a form.", [string])
936
+ :- close_form(FormId), Result = ok)).
937
+
938
+ %% form_validate_values(+SessionId, +Values, -IssueDicts)
939
+ %% Runs the harness's hard rules if it defines `form_validate/2`, and normalises
940
+ %% whatever it returns (`error/2`, `warning/2`, or an issue dict) into issue
941
+ %% dicts. With no such predicate, there are no hard errors.
942
+ form_validate_values(SessionId, Values, IssueDicts) :-
943
+ ( catch((call(SessionId:form_validate(Values, Issues)), is_list(Issues)), _, fail)
944
+ -> form_issue_dicts(Issues, IssueDicts)
945
+ ; IssueDicts = []
946
+ ).
947
+
948
+ %% repl_normalize_message(+Text, -TermText)
949
+ %% The host `call`/`/call` entry takes the request as a term *as text*. Prose is
950
+ %% not a term, and interpolating it into `request(From, Ref, Message)` produced a
951
+ %% goal the service could not parse — so it never replied and the caller waited
952
+ %% out the timeout. Anything that does not parse is quoted as a string; a valid
953
+ %% term passes through unchanged.
954
+ repl_normalize_message(Text, TermText) :-
955
+ ( catch((term_string(_Term, Text, []), true), _, fail)
956
+ -> TermText = Text
957
+ ; format(string(TermText), "~q", [Text])
958
+ ).
959
+
960
+ %% form_advisory_values(+SessionId, +Values, -IssueDicts)
961
+ %% The advisory twin of `form_validate_values/3`: reads the harness's
962
+ %% `form_advisories/2` when it defines one. Never blocks anything.
963
+ form_advisory_values(SessionId, Values, IssueDicts) :-
964
+ ( catch((call(SessionId:form_advisories(Values, Issues)), is_list(Issues)), _, fail)
965
+ -> form_issue_dicts(Issues, IssueDicts)
966
+ ; IssueDicts = []
967
+ ).
968
+
805
969
  %% process_directive(require_sandbox, +SessionId)
806
970
  %% The harness declares that it must run in an execution sandbox, and the load
807
971
  %% fails if it cannot. Checked against the **effective** backend rather than
@@ -839,6 +1003,10 @@ repl_benign_directive(discontiguous(_)).
839
1003
  repl_benign_directive(discontiguous(_, _)).
840
1004
  repl_benign_directive(multifile(_)).
841
1005
  repl_benign_directive(multifile(_, _)).
1006
+ %% The capability gate was removed — every host feature is always available. An
1007
+ %% older file that still declares one loads unchanged; the directive is ignored
1008
+ %% rather than reported as a mystery "unhandled directive".
1009
+ repl_benign_directive(capability(_)).
842
1010
 
843
1011
  %% sandbox_kind_atom(+Raw, -Atom)
844
1012
  %% A JS string or an atom in, an atom out.
@@ -1032,7 +1200,8 @@ create_session(SessionId, Params, HarnessMemory, MetaMemory) :-
1032
1200
  ),
1033
1201
  retractall(session_sandbox_backend(SessionId, _)),
1034
1202
  assertz(session_sandbox_backend(SessionId, SandboxKind)),
1035
- repl_inject_workspace_predicates(SessionId).
1203
+ repl_inject_workspace_predicates(SessionId),
1204
+ install_builtin_tools(SessionId).
1036
1205
 
1037
1206
  %% repl_abolish_user_predicates(+SessionId)
1038
1207
  %% Removes only the harness's own predicates; imported builtins are untouched.
@@ -1070,7 +1239,9 @@ clear_session(SessionId) :-
1070
1239
  retractall(repl_snapshot(SessionId, _)),
1071
1240
  retractall(session_user_tools(SessionId, _)),
1072
1241
  retractall(session_user_tool_schema(SessionId, _, _)),
1073
- repl_abolish_user_predicates(SessionId).
1242
+ retractall(session_builtin_tool(SessionId, _)),
1243
+ repl_abolish_user_predicates(SessionId),
1244
+ install_builtin_tools(SessionId).
1074
1245
 
1075
1246
  %% destroy_session(+SessionId)
1076
1247
  %% Tears a REPL session down completely: clears the module/frames and removes
@@ -1345,6 +1516,13 @@ repl_user_predicate(_SessionId, Name, _Arity) :-
1345
1516
  history_predicate(Name),
1346
1517
  !,
1347
1518
  fail.
1519
+ %% Runtime-installed tools (the built-in form tools) are visible to `task/N` but
1520
+ %% are not part of the harness: they do not appear in `listing`, the manifest's
1521
+ %% predicates, the exported clauses, or the counts.
1522
+ repl_user_predicate(SessionId, Name, _Arity) :-
1523
+ session_builtin_tool(SessionId, Name),
1524
+ !,
1525
+ fail.
1348
1526
  repl_user_predicate(SessionId, Name, Arity) :-
1349
1527
  % User DML clauses are asserted (interpreted) into the session module.
1350
1528
  % Exclude predicates imported via use_module (e.g. library(clpfd)) and
@@ -1357,6 +1535,7 @@ repl_user_predicate(SessionId, Name, Arity) :-
1357
1535
 
1358
1536
  internal_session_predicate(param).
1359
1537
  internal_session_predicate(param_decl).
1538
+ internal_session_predicate(tool).
1360
1539
  internal_session_predicate(tool_source).
1361
1540
  internal_session_predicate('__session__').
1362
1541
  internal_session_predicate(Name) :- workspace_predicate_name(Name).
@@ -1513,7 +1692,14 @@ export_directives(SessionId) :-
1513
1692
  ).
1514
1693
 
1515
1694
  export_tools(SessionId) :-
1516
- findall(Name, session_user_tool_schema(SessionId, Name, _), Names0),
1695
+ % Built-in tools are re-installed on load (install_builtin_tools/1), so they
1696
+ % are not written into the harness — an export carries the harness, not the
1697
+ % runtime.
1698
+ findall(Name,
1699
+ ( session_user_tool_schema(SessionId, Name, _),
1700
+ \+ session_builtin_tool(SessionId, Name)
1701
+ ),
1702
+ Names0),
1517
1703
  sort(Names0, Names),
1518
1704
  forall(member(Name, Names),
1519
1705
  ( SessionId:tool_source(Name, Source)
@@ -1555,7 +1741,11 @@ export_stats(SessionId, Clauses, Tools, Scripts) :-
1555
1741
  ),
1556
1742
  PredCounts),
1557
1743
  sum_list(PredCounts, Clauses),
1558
- findall(Name, session_user_tool_schema(SessionId, Name, _), ToolNames0),
1744
+ findall(Name,
1745
+ ( session_user_tool_schema(SessionId, Name, _),
1746
+ \+ session_builtin_tool(SessionId, Name)
1747
+ ),
1748
+ ToolNames0),
1559
1749
  sort(ToolNames0, ToolNames),
1560
1750
  length(ToolNames, Tools),
1561
1751
  ( current_predicate(SessionId:script_file/2)
@@ -1572,7 +1762,22 @@ export_clause(Head, Body) :-
1572
1762
  %% format_tool_source(+Head, +Description, +Body, -SourceCode)
1573
1763
  %% Source text for a tool, used by `listing(tools)` and export. Reverses the
1574
1764
  %% load-time task/prompt rewrites so readers see `task/N`, not `task_named/3`.
1575
- format_tool_source(Head, none, Body, SourceCode) :-
1765
+ %% Render a tool clause back to source for `listing`/`export`.
1766
+ format_tool_source(Head, Description, Body, SourceCode) :-
1767
+ format_tool_source(Head, Description, none, Body, SourceCode).
1768
+
1769
+ %% With an explicit input-type list: `tool(Head, Desc, [object]) :- Body.`
1770
+ format_tool_source(Head, Description, Types, Body, SourceCode) :-
1771
+ Types \== none,
1772
+ !,
1773
+ copy_term(Head-Description-Types-Body, NH-ND-NT-NB),
1774
+ numbervars(NH-ND-NT-NB, 0, _),
1775
+ clean_clause_body(NB, CleanBody),
1776
+ ( CleanBody == true
1777
+ -> format(string(SourceCode), "tool(~q, ~q, ~q).", [NH, ND, NT])
1778
+ ; format(string(SourceCode), "tool(~q, ~q, ~q) :-~n ~q.", [NH, ND, NT, CleanBody])
1779
+ ).
1780
+ format_tool_source(Head, none, _Types, Body, SourceCode) :-
1576
1781
  !,
1577
1782
  copy_term(Head-Body, NH-NB),
1578
1783
  numbervars(NH-NB, 0, _),
@@ -1581,7 +1786,7 @@ format_tool_source(Head, none, Body, SourceCode) :-
1581
1786
  -> format(string(SourceCode), "tool(~q).", [NH])
1582
1787
  ; format(string(SourceCode), "tool(~q) :-~n ~q.", [NH, CleanBody])
1583
1788
  ).
1584
- format_tool_source(Head, Description, Body, SourceCode) :-
1789
+ format_tool_source(Head, Description, _Types, Body, SourceCode) :-
1585
1790
  copy_term(Head-Description-Body, NH-ND-NB),
1586
1791
  numbervars(NH-ND-NB, 0, _),
1587
1792
  clean_clause_body(NB, CleanBody),
@@ -2488,6 +2693,9 @@ tool_contract_violation(SessionId, Warning) :-
2488
2693
  clause(SessionId:tool(Head), Body),
2489
2694
  compound(Head),
2490
2695
  Head =.. [Name|HeadArgs],
2696
+ % Built-in tools call host handlers (`form/2`, …), which are not session
2697
+ % predicates by design — they are not the author's to define.
2698
+ \+ session_builtin_tool(SessionId, Name),
2491
2699
  length(HeadArgs, Arity),
2492
2700
  tool_body_goal(Body, Call),
2493
2701
  Call =.. [Callee|Args],
@@ -2500,12 +2708,23 @@ tool_contract_violation(SessionId, Warning) :-
2500
2708
  -> format(string(Warning),
2501
2709
  'tool ~w/~w declares a different arity than its body calls (~w/~w). A tool head and its delegate must match, with the output argument last.',
2502
2710
  [Name, Arity, Callee, CalleeArity])
2503
- ; \+ ( SessionId:current_predicate(Callee/CalleeArity) )
2711
+ ; \+ ( SessionId:current_predicate(Callee/CalleeArity) ; tool_host_predicate(Callee, CalleeArity) )
2504
2712
  -> format(string(Warning),
2505
2713
  'tool ~w/~w calls ~w/~w, which is not defined, so a nested agent gets "Tool not found". Define ~w/~w and delegate to it.',
2506
2714
  [Name, Arity, Callee, CalleeArity, Callee, CalleeArity])
2507
2715
  ).
2508
2716
 
2717
+ %% Host handlers (service_call/3, form/2, exec/2, …) are mi_call specials, not
2718
+ %% session clauses. A tool that wraps one is valid even though
2719
+ %% current_predicate/1 reports false.
2720
+ tool_host_predicate(Name, Arity) :-
2721
+ atom(Name),
2722
+ Name \== (:),
2723
+ integer(Arity),
2724
+ Arity >= 0,
2725
+ functor(Goal, Name, Arity),
2726
+ is_mi_special_predicate(Goal).
2727
+
2509
2728
  %% tool_body_goal(+Body, -FirstCall): a tool body must reach a call, not be empty.
2510
2729
  tool_body_goal(Body, Call) :-
2511
2730
  Body \== true,
@@ -2993,6 +3212,9 @@ process_engine_result(media(Payload), media, '', Payload) :- is_dict(Payload), !
2993
3212
  process_engine_result(log(Text), log, Text, none) :- !.
2994
3213
  process_engine_result(answer(Text), answer, Text, none) :- !.
2995
3214
  %% Current 8-arg format (with model spec from with_model/2)
3215
+ %% Conversation: the same loop with `mode: chat` (no finish, ends when the model stops).
3216
+ process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec, Mode), request_agent_loop, '',
3217
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec, mode: Mode}) :- !.
2996
3218
  process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec), request_agent_loop, '',
2997
3219
  payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec}) :- !.
2998
3220
  %% Prompt-family 6-arg format (tool scope + model spec; no reasoning/recipe)
@@ -3014,6 +3236,20 @@ process_engine_result(request_exec(Tool, Args), request_exec, '',
3014
3236
  process_engine_result(request_proc(Request), request_proc, '', Payload) :-
3015
3237
  proc_request_payload(Request, Payload).
3016
3238
 
3239
+ %% Forms (docs/DML_FORMS.md). The host owns the registry; these carry the
3240
+ %% operation and, for `wait_form`, the change to deliver.
3241
+ %% Conversation inbox: the host answers with the next item (see wait_next_item/3).
3242
+ process_engine_result(wait_next(Accept), wait_next, '', payload{accept: Accept}) :- !.
3243
+ process_engine_result(request_form(Op, Spec), request_form, '', payload{op: Op, spec: Spec}) :- !.
3244
+ process_engine_result(request_form(update, FormId, Patch), request_form, '',
3245
+ payload{op: update, formId: FormId, patch: Patch}) :- !.
3246
+ process_engine_result(request_form(errors, FormId, Issues), request_form, '',
3247
+ payload{op: errors, formId: FormId, issues: Issues}) :- !.
3248
+ process_engine_result(request_form(close, FormId), request_form, '',
3249
+ payload{op: close, formId: FormId}) :- !.
3250
+ process_engine_result(wait_form(FormId, Since, Timeout), wait_form, '',
3251
+ payload{formId: FormId, since: Since, timeout: Timeout}) :- !.
3252
+
3017
3253
  %% proc_request_payload(+Request, -Payload)
3018
3254
  proc_request_payload(self, payload{op: self}) :- !.
3019
3255
  proc_request_payload(spawn(Goal), payload{op: spawn, goal: Goal}) :- !.
@@ -3102,12 +3338,17 @@ destroy_engine(SessionId) :-
3102
3338
  post_agent_result(SessionId, Success, Variables, Messages) :-
3103
3339
  post_agent_result(SessionId, Success, Variables, Messages, none).
3104
3340
 
3105
- %% post_agent_result(+SessionId, +Success, +Variables, +Messages, +FullMemory)
3341
+ post_agent_result(SessionId, Success, Variables, Messages, FullMemory) :-
3342
+ post_agent_result(SessionId, Success, Variables, Messages, FullMemory, "").
3343
+
3344
+ %% post_agent_result(+SessionId, +Success, +Variables, +Messages, +FullMemory, +Error)
3106
3345
  %% Messages is a list of message{role: Role, content: Content} dicts.
3107
3346
  %% FullMemory optionally replaces the entire state memory, including compacted system summaries.
3108
- post_agent_result(SessionId, Success, Variables, Messages, FullMemory) :-
3347
+ %% Error is non-empty when the turn failed before producing a result (a model
3348
+ %% error), so a caller can say what went wrong instead of guessing.
3349
+ post_agent_result(SessionId, Success, Variables, Messages, FullMemory, Error) :-
3109
3350
  retractall(session_agent_result(SessionId, _)),
3110
- assertz(session_agent_result(SessionId, result{success: Success, variables: Variables, messages: Messages, full_memory: FullMemory})),
3351
+ assertz(session_agent_result(SessionId, result{success: Success, variables: Variables, messages: Messages, full_memory: FullMemory, error: Error})),
3111
3352
  post_signal_to_engine(SessionId, agent_done).
3112
3353
 
3113
3354
  %% post_exec_result(+SessionId, +Status, +Result)
@@ -3123,6 +3364,22 @@ post_proc_result(SessionId, Key, Value) :-
3123
3364
  assertz(session_proc_result(SessionId, Key, Value)),
3124
3365
  post_signal_to_engine(SessionId, proc_done).
3125
3366
 
3367
+ %% post_form_result(+SessionId, +Result)
3368
+ %% Reply to a forms primitive (`form/2`, `form_update/2`, `form_errors/2`,
3369
+ %% `await_form/2,3`, `close_form/1`). Result is `ok(...)` or `error(...)`.
3370
+ %% post_next_result(+SessionId, +Item)
3371
+ %% The host answers `wait_next` with `user("...")`, `message(Term)`, or `closed`
3372
+ %% when there is nobody left to talk to.
3373
+ post_next_result(SessionId, Item) :-
3374
+ retractall(session_next_result(SessionId, _)),
3375
+ assertz(session_next_result(SessionId, Item)),
3376
+ post_signal_to_engine(SessionId, next_done).
3377
+
3378
+ post_form_result(SessionId, Result) :-
3379
+ retractall(session_form_result(SessionId, _)),
3380
+ assertz(session_form_result(SessionId, Result)),
3381
+ post_signal_to_engine(SessionId, form_done).
3382
+
3126
3383
  %% post_sample_token_result(+SessionId, +Status, +TokenOrError)
3127
3384
  post_sample_token_result(SessionId, Status, TokenOrError) :-
3128
3385
  retractall(session_sample_token_result(SessionId, _)),
@@ -3511,9 +3768,22 @@ create_tool_engine(SessionId, ToolName, Args, ToolEngineId) :-
3511
3768
  Goal = (
3512
3769
  nb_setval(current_session_id, SessionId),
3513
3770
  catch(
3514
- (mi_call(Body, InitialState, _FinalState),
3515
- extract_tool_result_simple(TotalArity, InputArity, HeadArgs, ToolResult),
3516
- engine_yield(tool_finished(ToolResult))),
3771
+ ( mi_call(Body, InitialState, _FinalState),
3772
+ % A tool that declares an output but leaves it unbound has no
3773
+ % result. `extract_tool_result_simple/4` maps that to `true`, which
3774
+ % is how a `tool(fs_write(Path, Content), …)` (content taken for the
3775
+ % output) reported success while writing nothing.
3776
+ ( TotalArity > InputArity,
3777
+ last(HeadArgs, LastArg),
3778
+ var(LastArg)
3779
+ -> format(atom(NoResult),
3780
+ 'tool ~w returned no result: the last argument of the tool head is its output. To pass another input, give the predicate a result argument (e.g. fs_write(Path, Content, ok)) and declare its types with tool/3.',
3781
+ [ToolNameAtom]),
3782
+ engine_yield(tool_no_result(NoResult))
3783
+ ; extract_tool_result_simple(TotalArity, InputArity, HeadArgs, ToolResult),
3784
+ engine_yield(tool_finished(ToolResult))
3785
+ )
3786
+ ),
3517
3787
  Error,
3518
3788
  (handle_tool_engine_error(Error, ErrResult),
3519
3789
  engine_yield(tool_finished(ErrResult)))
@@ -3557,6 +3827,9 @@ step_tool_engine(ToolEngineId, Status, Content, Payload) :-
3557
3827
 
3558
3828
  %% process_tool_engine_result(+Result, -Status, -Content, -Payload)
3559
3829
  process_tool_engine_result(tool_finished(ToolResult), finished, '', payload{result: ToolResult}) :- !.
3830
+ %% A tool whose output argument was never bound: surface it as an error, not a
3831
+ %% silent success (see create_tool_engine/4).
3832
+ process_tool_engine_result(tool_no_result(Message), error, Message, none) :- !.
3560
3833
  process_tool_engine_result(request_exec(Tool, Args), request_exec, '',
3561
3834
  payload{toolName: Tool, args: Args}) :- !.
3562
3835
  process_tool_engine_result(request_sample_token(Prompt, Allowed), request_sample_token, '',
@@ -3564,6 +3837,8 @@ process_tool_engine_result(request_sample_token(Prompt, Allowed), request_sample
3564
3837
  process_tool_engine_result(request_llm(Messages), request_llm, '',
3565
3838
  payload{messages: Messages}) :- !.
3566
3839
  process_tool_engine_result(request_judgment(Payload), request_judgment, '', Payload) :- !.
3840
+ process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec, Mode), request_agent_loop, '',
3841
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec, mode: Mode}) :- !.
3567
3842
  process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext), request_agent_loop, '',
3568
3843
  payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext}) :- !.
3569
3844
  %% Legacy 5-arg format (no reasoning/recipe)
@@ -3576,6 +3851,17 @@ process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory), reques
3576
3851
  %% are yielded to the host exactly as in the main engine.
3577
3852
  process_tool_engine_result(request_proc(Request), request_proc, '', Payload) :-
3578
3853
  proc_request_payload(Request, Payload).
3854
+ %% Form primitives reached inside a DML tool (docs/DML_FORMS.md).
3855
+ process_tool_engine_result(wait_next(Accept), wait_next, '', payload{accept: Accept}) :- !.
3856
+ process_tool_engine_result(request_form(Op, Spec), request_form, '', payload{op: Op, spec: Spec}) :- !.
3857
+ process_tool_engine_result(request_form(update, FormId, Patch), request_form, '',
3858
+ payload{op: update, formId: FormId, patch: Patch}) :- !.
3859
+ process_tool_engine_result(request_form(errors, FormId, Issues), request_form, '',
3860
+ payload{op: errors, formId: FormId, issues: Issues}) :- !.
3861
+ process_tool_engine_result(request_form(close, FormId), request_form, '',
3862
+ payload{op: close, formId: FormId}) :- !.
3863
+ process_tool_engine_result(wait_form(FormId, Since, Timeout), wait_form, '',
3864
+ payload{formId: FormId, since: Since, timeout: Timeout}) :- !.
3579
3865
  process_tool_engine_result(output(Text), output, Text, none) :- !.
3580
3866
  process_tool_engine_result(log(Text), log, Text, none) :- !.
3581
3867
  process_tool_engine_result(Other, error, Msg, none) :-
@@ -3774,6 +4060,112 @@ mi_call(prompt_named(Desc, Vars, VarNames), StateIn, StateOut) :-
3774
4060
  consume_gas(StateIn, State1),
3775
4061
  mi_call_prompt_n(Desc, Vars, VarNames, State1, StateOut).
3776
4062
 
4063
+ %% ============================================================
4064
+ %% Conversation (docs/DML_CONVERSATION.md)
4065
+ %% ============================================================
4066
+
4067
+ %% mi_call(input(-Message), +StateIn, -StateOut)
4068
+ %% Wait for the person's next message. No prompt: the embedding UI is a chat
4069
+ %% interface and is already asking. This is `next/1` restricted to the person —
4070
+ %% a worker's message that arrives first stays queued for `next/1`. FAILS when
4071
+ %% there is nobody to talk to, which is what ends
4072
+ %% `loop :- input(A), chat(A).`
4073
+ mi_call(input(Message), StateIn, StateIn) :-
4074
+ !,
4075
+ get_session_id(SessionId),
4076
+ get_depth(StateIn, Depth),
4077
+ wait_next_item(SessionId, user, Item),
4078
+ ( Item = user(Message)
4079
+ -> add_trace_with_result(SessionId, exit, input, [""], Message, Depth)
4080
+ ; fail
4081
+ ).
4082
+
4083
+ %% mi_call(next(-Item), +StateIn, -StateOut)
4084
+ %% The next item from the conversation inbox: `user(Text)` from the person, or a
4085
+ %% message a worker sent to this session. Fails when nothing is left, so
4086
+ %% `loop :- next(Item), handle(Item), loop.` ends cleanly.
4087
+ mi_call(next(Item), StateIn, StateIn) :-
4088
+ !,
4089
+ get_session_id(SessionId),
4090
+ wait_next_item(SessionId, any, Item),
4091
+ Item \== closed.
4092
+
4093
+ %% wait_next_item(+SessionId, +Accept, -Item)
4094
+ %% One host round trip: the runner owns the inbox (it is the only place that can
4095
+ %% see both the person and the mailbox), and answers with the next item.
4096
+ wait_next_item(SessionId, Accept, Item) :-
4097
+ engine_yield(wait_next(Accept)),
4098
+ get_next_signal(SessionId, _),
4099
+ session_next_result(SessionId, Item),
4100
+ retractall(session_next_result(SessionId, _)).
4101
+
4102
+ %% mi_call(chat(+Message), +StateIn, -StateOut) / chat(+Message, -Reply)
4103
+ %%
4104
+ %% One conversational turn: `Message` is the person's message, the agent loop runs
4105
+ %% with the harness's tools, and the turn ends when the model stops calling tools.
4106
+ %% No `finish`, no `set_result` and no nudging — the reply *is* the answer
4107
+ %% (agent.ts `conversational`). Memory carries the conversation forward, so
4108
+ %% `loop :- input(A), chat(A).` is a conversation in one run.
4109
+ mi_call(chat(Message), StateIn, StateOut) :-
4110
+ !,
4111
+ mi_call(chat(Message, _Reply), StateIn, StateOut).
4112
+
4113
+ mi_call(chat(Message, Reply), StateIn, StateOut) :-
4114
+ !,
4115
+ consume_gas(StateIn, State0),
4116
+ get_params(State0, Params),
4117
+ interpolate_desc(Message, Params, InterpMessage),
4118
+ get_session_id(SessionId),
4119
+ get_depth(StateIn, Depth),
4120
+ add_trace_entry(SessionId, llm_call, chat, [InterpMessage], Depth),
4121
+ collect_user_tools(SessionId, UserTools),
4122
+ % The message is NOT added to memory here: the loop appends it as the turn's
4123
+ % user message, and returns the whole conversation as `full_memory`.
4124
+ get_memory(State0, Memory0),
4125
+ get_tool_scope(State0, ToolScope),
4126
+ get_reasoning_effort(State0, ReasoningEffort),
4127
+ get_recipe_context(State0, RecipeContext),
4128
+ get_model_spec(State0, ModelSpec),
4129
+ engine_yield(request_agent_loop(InterpMessage, [], UserTools, Memory0, ToolScope,
4130
+ ReasoningEffort, RecipeContext, ModelSpec, chat)),
4131
+ handle_agent_signals(SessionId, State0, StateAfterAgent),
4132
+ session_agent_result(SessionId, Result),
4133
+ retract(session_agent_result(SessionId, _)),
4134
+ % A conversation must survive a failed turn: say so, leave the memory as it
4135
+ % was (the failed turn is not part of the conversation), and let the person
4136
+ % type again. Failing here would end `loop :- input(A), chat(A), loop.` and
4137
+ % drop them out of the chat.
4138
+ ( get_dict(success, Result, true)
4139
+ -> ( get_dict(full_memory, Result, FullMemory), is_list(FullMemory)
4140
+ -> set_memory(StateAfterAgent, FullMemory, StateOut)
4141
+ ; set_memory(StateAfterAgent, Memory0, StateOut)
4142
+ ),
4143
+ last_assistant_message(Result, Reply),
4144
+ add_trace_with_result(SessionId, exit, chat, [InterpMessage], Reply, Depth)
4145
+ ; ( get_dict(error, Result, ErrorMsg), ErrorMsg \= ""
4146
+ -> format(string(WarnMsg),
4147
+ 'The model call failed (~w). Your message was not answered — try again.', [ErrorMsg])
4148
+ ; WarnMsg = 'The model call failed. Your message was not answered — try again.'
4149
+ ),
4150
+ engine_yield(output(WarnMsg)),
4151
+ Reply = "",
4152
+ set_memory(StateAfterAgent, Memory0, StateOut),
4153
+ add_trace_entry(SessionId, fail, chat, [InterpMessage], Depth)
4154
+ ).
4155
+
4156
+ %% last_assistant_message(+AgentResult, -Text)
4157
+ %% The reply is also streamed to the UI; this binds it for a harness that wants it.
4158
+ last_assistant_message(Result, Text) :-
4159
+ ( get_dict(full_memory, Result, Messages), is_list(Messages)
4160
+ -> true
4161
+ ; Messages = []
4162
+ ),
4163
+ findall(Content, member(message{role: assistant, content: Content}, Messages), Texts),
4164
+ ( last(Texts, Text)
4165
+ -> true
4166
+ ; Text = ""
4167
+ ).
4168
+
3777
4169
  %% mi_call_prompt_n(+Desc, +Vars, +VarNames, +StateIn, -StateOut)
3778
4170
  %% Simple LLM call with empty memory and NO tools (pure text completion with variable binding)
3779
4171
  mi_call_prompt_n(Desc, Vars, VarNames, StateIn, StateOut) :-
@@ -4299,7 +4691,7 @@ mi_call(spawn_opt(Goal, Pid, Options), StateIn, StateIn) :-
4299
4691
  get_session_id(SessionId),
4300
4692
  copy_term(Goal, GoalCopy),
4301
4693
  term_string(GoalCopy, GoalText),
4302
- ( is_dict(Options) -> term_string(Options, OptionsText) ; OptionsText = "{}" ),
4694
+ spawn_options_text(Options, OptionsText),
4303
4695
  engine_yield(request_proc(spawn(GoalText, OptionsText))),
4304
4696
  get_next_signal(SessionId, _),
4305
4697
  session_proc_result(SessionId, spawn, SpawnResult),
@@ -4309,6 +4701,39 @@ mi_call(spawn_opt(Goal, Pid, Options), StateIn, StateIn) :-
4309
4701
  ; throw(error(process_spawn_failed(SpawnResult), spawn_opt/3))
4310
4702
  ).
4311
4703
 
4704
+ %% spawn_options_text(+Options, -Text)
4705
+ %% `spawn_opt/3` takes an options **dict** (`_{name: worker}`), but the obvious
4706
+ %% Prolog spelling is a list (`[name(worker)]`) or a single option
4707
+ %% (`name(worker)`). A non-dict used to be silently ignored, so the process kept
4708
+ %% its default label and nothing said so — the agent's `name(tstsvc)` did nothing,
4709
+ %% and two of this repo's own tests pass a list and believe they named a worker.
4710
+ spawn_options_text(Options, Text) :-
4711
+ ( Options == []
4712
+ -> Text = "{}"
4713
+ ; is_dict(Options)
4714
+ -> term_string(Options, Text)
4715
+ ; ( is_list(Options) -> List = Options ; List = [Options] ),
4716
+ spawn_option_pairs(List, Pairs),
4717
+ dict_pairs(Dict, _, Pairs),
4718
+ term_string(Dict, Text)
4719
+ ).
4720
+
4721
+ spawn_option_pairs([], []).
4722
+ spawn_option_pairs([Option|Rest], [Key-Value|Pairs]) :-
4723
+ spawn_option(Option, Key, Value),
4724
+ spawn_option_pairs(Rest, Pairs).
4725
+
4726
+ spawn_option(name(V), name, V) :- !.
4727
+ spawn_option(priority(V), priority, V) :- !.
4728
+ spawn_option(timeout(V), timeout, V) :- !.
4729
+ spawn_option(code(V), code, V) :- !.
4730
+ spawn_option(Key-V, Key, V) :- atom(Key), !.
4731
+ spawn_option(Option, _, _) :-
4732
+ format(atom(Message),
4733
+ 'spawn_opt/3 does not understand the option ~w (use name(...), priority(...), timeout(...), code(...), or a dict)',
4734
+ [Option]),
4735
+ throw(error(domain_error(spawn_option, Option), Message)).
4736
+
4312
4737
  %% mi_call(send(+Target, +Message), +StateIn, -StateOut)
4313
4738
  mi_call(send(To, Message), StateIn, StateIn) :-
4314
4739
  !,
@@ -4484,22 +4909,53 @@ mi_call(now_ms(Milliseconds), StateIn, StateIn) :-
4484
4909
  %% mi_call(serve(+Name, +Goal), +StateIn, -StateOut)
4485
4910
  %% Spawn a session-scoped process and register it under Name. Sugar over
4486
4911
  %% spawn/2 + register/2; the goal decides the process's shape.
4912
+ %%
4913
+ %% The process is *labelled* with the service name. A spawn labels a process with
4914
+ %% its goal functor, so `serve(test_service, test_service_loop)` used to appear in
4915
+ %% `ps` as "test_service_loop" — the label of an anonymous loop — and a live
4916
+ %% service looked like something nobody could talk to. `process_info/2` keeps the
4917
+ %% goal, so nothing is lost.
4487
4918
  mi_call(serve(Name, Goal), StateIn, StateIn) :-
4488
4919
  !,
4489
4920
  get_session_id(SessionId),
4490
4921
  copy_term(Goal, GoalCopy),
4491
4922
  term_string(GoalCopy, GoalText),
4492
- engine_yield(request_proc(spawn(GoalText))),
4923
+ ( atom(Name)
4924
+ -> Label = Name
4925
+ ; atom_string(Label, Name) % a string name -> atom
4926
+ ),
4927
+ % Spawn options are read as a dict (`parseSpawnOptions`), so `name:` not `name(...)`.
4928
+ format(string(OptionsText), "{name: ~q}", [Label]),
4929
+ engine_yield(request_proc(spawn(GoalText, OptionsText))),
4493
4930
  get_next_signal(SessionId, _),
4494
4931
  session_proc_result(SessionId, spawn, SpawnResult),
4495
4932
  retract(session_proc_result(SessionId, spawn, _)),
4496
- SpawnResult = ok(Pid),
4933
+ % A failed spawn must be an error, not a silent failure: unifying with ok/1
4934
+ % used to fail, so `serve/2` looked like a predicate with no solutions.
4935
+ ( SpawnResult = ok(Pid)
4936
+ -> true
4937
+ ; throw(error(process_spawn_failed(SpawnResult), serve/2))
4938
+ ),
4497
4939
  term_string(Name, NameText),
4498
4940
  term_string(Pid, PidText),
4499
4941
  engine_yield(request_proc(register(NameText, PidText))),
4500
4942
  get_next_signal(SessionId, _),
4501
- session_proc_result(SessionId, register, _),
4502
- retract(session_proc_result(SessionId, register, _)).
4943
+ session_proc_result(SessionId, register, RegisterResult),
4944
+ retract(session_proc_result(SessionId, register, _)),
4945
+ ( RegisterResult == ok
4946
+ -> true
4947
+ ; throw(error(process_register_failed(RegisterResult), serve/2))
4948
+ ).
4949
+
4950
+ %% mi_call(serve(+Name, +Goal, -Pid), +StateIn, -StateOut)
4951
+ %% `serve/2` registers and binds nothing; the pid is what a caller needs next (to
4952
+ %% `wait`, `stop` or link it), and `spawn/2` already binds one. So this is the same
4953
+ %% call with the pid out — not a third "options" argument (`serve/3` did not exist,
4954
+ %% which is a guess the argument shapes invite).
4955
+ mi_call(serve(Name, Goal, Pid), StateIn, StateOut) :-
4956
+ !,
4957
+ mi_call(serve(Name, Goal), StateIn, State1),
4958
+ mi_call(whereis(Name, Pid), State1, StateOut).
4503
4959
 
4504
4960
  %% mi_call(service_cast(+Target, +Message), +StateIn, -StateOut)
4505
4961
  mi_call(service_cast(Target, Message), StateIn, StateIn) :-
@@ -4896,6 +5352,111 @@ mi_call(input(Prompt, Input), StateIn, StateIn) :-
4896
5352
  add_trace_with_result(SessionId, exit, input, [InterpPrompt], "", Depth)
4897
5353
  ).
4898
5354
 
5355
+ %% ============================================================
5356
+ %% Forms (docs/DML_FORMS.md)
5357
+ %% ============================================================
5358
+
5359
+ %% mi_call(form(+Spec, -FormId), +StateIn, -StateOut)
5360
+ mi_call(form(Spec, FormId), StateIn, StateIn) :-
5361
+ !,
5362
+ get_session_id(SessionId),
5363
+ engine_yield(request_form(open, Spec)),
5364
+ get_next_signal(SessionId, _),
5365
+ session_form_result(SessionId, Result),
5366
+ retract(session_form_result(SessionId, _)),
5367
+ ( Result = ok(Dict)
5368
+ -> get_dict(formId, Dict, FormId)
5369
+ ; throw(error(form_open_failed(Result), form/2))
5370
+ ).
5371
+
5372
+ %% mi_call(form_update(+FormId, +Patch), +StateIn, -StateOut)
5373
+ mi_call(form_update(FormId, Patch), StateIn, StateIn) :-
5374
+ !,
5375
+ get_session_id(SessionId),
5376
+ engine_yield(request_form(update, FormId, Patch)),
5377
+ get_next_signal(SessionId, _),
5378
+ session_form_result(SessionId, _), retract(session_form_result(SessionId, _)).
5379
+
5380
+ %% mi_call(form_errors(+FormId, +Issues), +StateIn, -StateOut)
5381
+ %% Issues are `error(Path, Message)` / `warning(Path, Message)` terms.
5382
+ mi_call(form_errors(FormId, Issues), StateIn, StateIn) :-
5383
+ !,
5384
+ form_issue_dicts(Issues, Dicts),
5385
+ get_session_id(SessionId),
5386
+ engine_yield(request_form(errors, FormId, Dicts)),
5387
+ get_next_signal(SessionId, _),
5388
+ session_form_result(SessionId, _), retract(session_form_result(SessionId, _)).
5389
+
5390
+ %% mi_call(close_form(+FormId), +StateIn, -StateOut)
5391
+ mi_call(close_form(FormId), StateIn, StateIn) :-
5392
+ !,
5393
+ get_session_id(SessionId),
5394
+ engine_yield(request_form(close, FormId)),
5395
+ get_next_signal(SessionId, _),
5396
+ session_form_result(SessionId, _), retract(session_form_result(SessionId, _)).
5397
+
5398
+ %% mi_call(await_form(+FormId, -Change) / await_form(+FormId, +Since, -Change))
5399
+ %% The 2-arity form waits for the next change (the registry's cursor).
5400
+ %%
5401
+ %% A submit the host's rules reject never reaches the caller: the host returns
5402
+ %% `action: invalid` with the errors, and this loops — attaching them to the form
5403
+ %% so the user sees what is wrong, then waiting for the next attempt. So a harness
5404
+ %% (or a model's `wait_for_form_change`) only ever sees an action the rules
5405
+ %% accepted, and neither has to write the reject-and-re-show loop.
5406
+ mi_call(await_form(FormId, Change), StateIn, StateIn) :-
5407
+ !,
5408
+ mi_call(await_form(FormId, -1, Change), StateIn, StateIn).
5409
+ mi_call(await_form(FormId, Since, Change), StateIn, StateIn) :-
5410
+ !,
5411
+ get_session_id(SessionId),
5412
+ await_form_loop(SessionId, FormId, Since, StateIn, Change).
5413
+
5414
+ await_form_loop(SessionId, FormId, Since, StateIn, Change) :-
5415
+ engine_yield(wait_form(FormId, Since, 0)),
5416
+ get_next_signal(SessionId, _),
5417
+ session_form_result(SessionId, Result),
5418
+ retract(session_form_result(SessionId, _)),
5419
+ ( Result = ok(Dict0)
5420
+ -> form_change_atom_action(Dict0, Dict),
5421
+ ( get_dict(action, Dict, invalid)
5422
+ -> ( get_dict(errors, Dict, Errors)
5423
+ -> mi_call(form_errors(FormId, Errors), StateIn, _)
5424
+ ; true
5425
+ ),
5426
+ % Follow the registry's cursor, not the caller's original `Since`,
5427
+ % so the retry waits for a new attempt instead of re-reading the
5428
+ % submit that was just refused.
5429
+ await_form_loop(SessionId, FormId, -1, StateIn, Change)
5430
+ ; Change = Dict
5431
+ )
5432
+ ; throw(error(form_wait_failed(Result), await_form/3))
5433
+ ).
5434
+
5435
+ %% form_change_atom_action(+Change0, -Change)
5436
+ %% The host builds the change from JSON, so `action` arrives as a *string*. DML
5437
+ %% compares it to an atom — `Change.action == submit` (docs/DML_FORMS.md §3) — and
5438
+ %% `"submit" == submit` is false, which silently skipped every action branch.
5439
+ %% Convert here: the atom is the structural tag, and the model-facing tools
5440
+ %% re-serialize the dict to JSON, where an atom is a string again.
5441
+ form_change_atom_action(Change0, Change) :-
5442
+ ( get_dict(action, Change0, Action0), string(Action0)
5443
+ -> atom_string(Action, Action0),
5444
+ put_dict(action, Change0, Action, Change)
5445
+ ; Change = Change0
5446
+ ).
5447
+
5448
+ %% form_issue_dicts(+Issues, -Dicts)
5449
+ form_issue_dicts([], []).
5450
+ form_issue_dicts([Issue|Rest], [Dict|Dicts]) :-
5451
+ issue_dict(Issue, Dict),
5452
+ form_issue_dicts(Rest, Dicts).
5453
+
5454
+ issue_dict(error(Path, Message), issue{path: Path, level: error, message: Message}) :- !.
5455
+ issue_dict(warning(Path, Message), issue{path: Path, level: warning, message: Message}) :- !.
5456
+ issue_dict(issue{path: Path, level: Level, message: Message}, issue{path: Path, level: Level, message: Message}) :- !.
5457
+ %% A dict already shaped like an issue (e.g. parsed from JSON) passes through.
5458
+ issue_dict(Issue, Issue) :- is_dict(Issue), !.
5459
+
4899
5460
  %% mi_call(yield(Text), +StateIn, -StateOut)
4900
5461
  mi_call(yield(Text), StateIn, StateIn) :-
4901
5462
  get_params(StateIn, Params),
@@ -4923,6 +5484,22 @@ mi_call(format(Format, Args), StateIn, StateIn) :-
4923
5484
  ; format(Format, Args)
4924
5485
  ).
4925
5486
 
5487
+ %% mi_call(write(Value), +StateIn, -StateOut)
5488
+ %% `write/1` had no handler, so it fell through to SWI's current_output and its
5489
+ %% text vanished in the REPL — only `format/2` and `writeln/1` were captured. A
5490
+ %% debugging `write(step)` then looks exactly like a goal that produced nothing,
5491
+ %% which is the worst possible signal.
5492
+ mi_call(write(Value), StateIn, StateIn) :-
5493
+ get_session_id(SessionId),
5494
+ ( session_repl_mode(SessionId, true)
5495
+ -> with_output_to(string(Text), write(Value)),
5496
+ ( Text == ""
5497
+ -> true
5498
+ ; mi_call(output(Text), StateIn, StateIn)
5499
+ )
5500
+ ; write(Value)
5501
+ ).
5502
+
4926
5503
  %% mi_call(writeln(Value), +StateIn, -StateOut)
4927
5504
  mi_call(writeln(Value), StateIn, StateIn) :-
4928
5505
  get_session_id(SessionId),
@@ -5756,7 +6333,7 @@ consult_string(Code, SessionId) :-
5756
6333
  consult_clauses(Stream, SessionId) :-
5757
6334
  read_term(Stream, Term, [module(SessionId), variable_names(Bindings)]),
5758
6335
  ( Term == end_of_file
5759
- -> true
6336
+ -> install_builtin_tools(SessionId)
5760
6337
  ; preprocess_loaded_term(Term, Bindings, PreparedTerms),
5761
6338
  forall(member(PreparedTerm, PreparedTerms), consult_process_term(PreparedTerm, SessionId)),
5762
6339
  consult_clauses(Stream, SessionId)
@@ -6014,6 +6591,7 @@ is_mi_special_predicate(sleep(_)).
6014
6591
  is_mi_special_predicate(make_ref(_)).
6015
6592
  is_mi_special_predicate(now_ms(_)).
6016
6593
  is_mi_special_predicate(serve(_,_)).
6594
+ is_mi_special_predicate(serve(_,_,_)).
6017
6595
  is_mi_special_predicate(service_cast(_,_)).
6018
6596
  is_mi_special_predicate(service_call(_,_,_)).
6019
6597
  is_mi_special_predicate(service_call(_,_,_,_)).
@@ -6024,6 +6602,13 @@ is_mi_special_predicate(wait(_)).
6024
6602
  is_mi_special_predicate(wait(_,_)).
6025
6603
  is_mi_special_predicate(param(_,_)).
6026
6604
  is_mi_special_predicate(param(_,_,_)).
6605
+ %% Forms (docs/DML_FORMS.md)
6606
+ is_mi_special_predicate(form(_,_)).
6607
+ is_mi_special_predicate(form_update(_,_)).
6608
+ is_mi_special_predicate(form_errors(_,_)).
6609
+ is_mi_special_predicate(await_form(_,_)).
6610
+ is_mi_special_predicate(await_form(_,_,_)).
6611
+ is_mi_special_predicate(close_form(_)).
6027
6612
  %% Prompt predicates (like task but with fresh memory)
6028
6613
  is_mi_special_predicate(prompt(_)).
6029
6614
  is_mi_special_predicate(prompt(_,_)).
@@ -6054,7 +6639,13 @@ is_mi_special_predicate(require_judgment(_,_)).
6054
6639
  is_mi_special_predicate(get_memory(_)).
6055
6640
  is_mi_special_predicate(format(_,_)).
6056
6641
  is_mi_special_predicate(writeln(_)).
6642
+ is_mi_special_predicate(write(_)).
6057
6643
  is_mi_special_predicate(input(_,_)).
6644
+ %% Conversation (docs/DML_CONVERSATION.md)
6645
+ is_mi_special_predicate(input(_)).
6646
+ is_mi_special_predicate(chat(_)).
6647
+ is_mi_special_predicate(chat(_,_)).
6648
+ is_mi_special_predicate(next(_)).
6058
6649
  is_mi_special_predicate(call(_)).
6059
6650
  is_mi_special_predicate(call(_,_)).
6060
6651
  is_mi_special_predicate(call(_,_,_)).