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
@@ -89,6 +89,9 @@
89
89
  repl_commit/3,
90
90
  repl_last_commit_summary/2,
91
91
  repl_execute_frame/4,
92
+ read_workspace_file/3,
93
+ workspace_read_path/3,
94
+ read_workspace_chars/2,
92
95
  repl_record_meta/3,
93
96
  repl_memory_term/3,
94
97
  repl_get_memory/3,
@@ -106,7 +109,13 @@
106
109
  create_tool_engine/4,
107
110
  step_tool_engine/4,
108
111
  destroy_tool_engine/1,
109
- post_tool_agent_result/5
112
+ post_tool_agent_result/5,
113
+ % Forms (docs/DML_FORMS.md)
114
+ post_form_result/2,
115
+ post_next_result/2,
116
+ form_validate_values/3,
117
+ form_advisory_values/3,
118
+ repl_normalize_message/2
110
119
  ]).
111
120
 
112
121
  :- use_module(deepclause_strings).
@@ -118,6 +127,7 @@
118
127
  %% `proc_request_payload/2` sits between the process-primitive clauses of
119
128
  %% `process_engine_result/4`, which is intentional.
120
129
  :- discontiguous process_engine_result/4.
130
+ :- discontiguous process_directive/2.
121
131
 
122
132
  %% Dynamic predicates for session state (engine management only, not memory!)
123
133
  :- dynamic session_engine/2. % session_engine(SessionId, Engine) - legacy default engine handle
@@ -155,6 +165,10 @@
155
165
  :- dynamic session_proc_result/3. % session_proc_result(SessionId, Key, Value) - process primitive results
156
166
  :- dynamic session_user_input/2. % session_user_input(SessionId, Input) - for ask_user tool
157
167
  :- dynamic session_input_cancelled/1. % session_input_cancelled(SessionId) - input/2 should fail (non-interactive)
168
+ :- dynamic session_form_result/2. % session_form_result(SessionId, Result) - forms primitive results
169
+ :- dynamic session_builtin_tool/2. % session_builtin_tool(SessionId, ToolName) - runtime-installed tools
170
+ :- dynamic session_next_result/2. % session_next_result(SessionId, Item) - conversation inbox item
171
+ :- dynamic session_resource/2. % session_resource(SessionId, Resource) - held stream/form (docs/DML_REPL_SUSPEND_RESUME.md)
158
172
 
159
173
  %% ============================================================
160
174
  %% DML Parsing
@@ -201,7 +215,7 @@ parse_dml_string(Code, SessionId) :-
201
215
  parse_clauses(Stream, SessionId) :-
202
216
  read_term(Stream, Term, [module(SessionId), variable_names(Bindings)]),
203
217
  ( Term == end_of_file
204
- -> true
218
+ -> install_builtin_tools(SessionId)
205
219
  ; preprocess_loaded_term(Term, Bindings, PreparedTerms),
206
220
  forall(member(PreparedTerm, PreparedTerms), process_clause(PreparedTerm, SessionId)),
207
221
  parse_clauses(Stream, SessionId)
@@ -573,6 +587,19 @@ process_clause((:- Directive), SessionId) :-
573
587
  !,
574
588
  process_directive(Directive, SessionId).
575
589
 
590
+ %% Handle tool/3: description plus per-input types.
591
+ %% tool(Head, Description, [object, number]) :- Body.
592
+ %% The leading args are inputs (typed from the list); the last arg is the output.
593
+ process_clause((tool(ToolHead, Description, Types) :- Body), SessionId) :-
594
+ !,
595
+ (catch(expand_dict_dots(Body, ExpandedBody), _, fail) -> true ; ExpandedBody = Body),
596
+ extract_tool_schema(ToolHead, Description, Types, ToolName, Schema),
597
+ assertz(session_user_tools(SessionId, ToolName)),
598
+ assertz(session_user_tool_schema(SessionId, ToolName, Schema)),
599
+ format_tool_source(ToolHead, Description, Types, Body, SourceCode),
600
+ assertz(SessionId:tool_source(ToolName, SourceCode)),
601
+ assertz(SessionId:(tool(ToolHead) :- ExpandedBody)).
602
+
576
603
  %% Handle tool/2 with description: tool(Head, Description) :- Body
577
604
  process_clause((tool(ToolHead, Description) :- Body), SessionId) :-
578
605
  !,
@@ -693,9 +720,17 @@ expand_dict_expr(Expr, Expr).
693
720
 
694
721
  %% extract_tool_schema(+ToolHead, +Description, -ToolName, -Schema)
695
722
  extract_tool_schema(ToolHead, Description, ToolName, Schema) :-
723
+ extract_tool_schema(ToolHead, Description, [], ToolName, Schema).
724
+
725
+ %% extract_tool_schema(+Head, +Description, +Types, -ToolName, -Schema)
726
+ %% `Types` is an optional list giving the type of each input (the leading args,
727
+ %% in order); a missing entry defaults to `string`. This is what lets a tool take
728
+ %% an object, an array or a number rather than only a string.
729
+ extract_tool_schema(ToolHead, Description, Types, ToolName, Schema) :-
696
730
  ToolHead =.. [ToolName|Args],
697
731
  length(Args, Arity),
698
- extract_params(Args, 1, Arity, Inputs, Outputs),
732
+ tool_type_warning(ToolName, Arity, Types),
733
+ extract_params(Args, 1, Arity, Types, Inputs, Outputs),
699
734
  (Description == none -> Desc = "" ; Desc = Description),
700
735
  Schema = schema{
701
736
  name: ToolName,
@@ -704,19 +739,45 @@ extract_tool_schema(ToolHead, Description, ToolName, Schema) :-
704
739
  outputs: Outputs
705
740
  }.
706
741
 
707
- %% extract_params(+Args, +Index, +Arity, -Inputs, -Outputs)
708
- extract_params([], _, _, [], []) :- !.
709
- extract_params([_Arg|Rest], Index, Arity, Inputs, Outputs) :-
742
+ %% extract_params(+Args, +Index, +Arity, +Types, -Inputs, -Outputs)
743
+ %% The last argument is the output; the rest are inputs, typed from `Types`.
744
+ extract_params([], _, _, _, [], []) :- !.
745
+ extract_params([_Arg|Rest], Index, Arity, Types, Inputs, Outputs) :-
710
746
  format(atom(Name), 'arg~w', [Index]),
711
- Type = string,
712
747
  NextIndex is Index + 1,
713
- extract_params(Rest, NextIndex, Arity, RestInputs, RestOutputs),
714
- Param = param{name: Name, type: Type},
748
+ extract_params(Rest, NextIndex, Arity, Types, RestInputs, RestOutputs),
715
749
  ( Index == Arity
750
+ -> IsOutput = true, Type = string
751
+ ; IsOutput = false, input_type(Index, Types, Type)
752
+ ),
753
+ Param = param{name: Name, type: Type},
754
+ ( IsOutput == true
716
755
  -> Inputs = RestInputs, Outputs = [Param|RestOutputs]
717
756
  ; Inputs = [Param|RestInputs], Outputs = RestOutputs
718
757
  ).
719
758
 
759
+ %% input_type(+Index, +Types, -Type): the Index-th input's declared type.
760
+ input_type(1, [T|_], T) :- !.
761
+ input_type(N, [_|Rest], T) :- N > 1, N1 is N - 1, input_type(N1, Rest, T).
762
+ input_type(_, [], string).
763
+
764
+ %% tool_type_warning(+Name, +Arity, +Types)
765
+ %% The last argument of a tool head is the output, so a type list should label
766
+ %% the other (Arity - 1) inputs. When it does not, the author has almost
767
+ %% certainly written a tool whose second input was taken for its output — the
768
+ %% `tool(fs_write(Path, Content), …)` shape, which silently writes nothing.
769
+ tool_type_warning(ToolName, Arity, Types) :-
770
+ is_list(Types),
771
+ Types \== [],
772
+ length(Types, Declared),
773
+ Inputs is Arity - 1,
774
+ Declared =\= Inputs,
775
+ !,
776
+ format(user_error,
777
+ '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',
778
+ [ToolName, Arity, Declared, Inputs]).
779
+ tool_type_warning(_, _, _).
780
+
720
781
  module_reference_string(Module, ModuleStr) :-
721
782
  atom(Module),
722
783
  !,
@@ -769,7 +830,7 @@ process_directive(consult(File), SessionId) :-
769
830
  !,
770
831
  % Set session ID for get_session_id to find, then call our custom consult
771
832
  nb_setval(current_session_id, SessionId),
772
- system:read_file_to_string(File, Code, []),
833
+ read_workspace_file(SessionId, File, Code),
773
834
  consult_string(Code, SessionId).
774
835
  %% Handle use_module directive
775
836
  process_directive(use_module(Module), SessionId) :-
@@ -802,6 +863,113 @@ process_directive(repl_delete(Name/Arity), SessionId) :-
802
863
  Arity >= 0,
803
864
  catch(abolish(SessionId:Name/Arity), _, true).
804
865
 
866
+ %% ============================================================
867
+ %% Built-in tools (docs/DML_FORMS.md)
868
+ %% ============================================================
869
+ %%
870
+ %% These `tool/2` wrappers are installed into every session, so a harness can
871
+ %% hand the model the form tools without declaring anything. They are ordinary
872
+ %% `tool/N` clauses: assert them through `process_clause/2` (never into a
873
+ %% harness's own clause set) and `task/N` offers them, `with_tools/2` scopes
874
+ %% them, and an export carries the source so a standalone run works.
875
+ %%
876
+ %% The gate that used to guard this — `:- capability(forms).` — was removed: it
877
+ %% was file-scoped (processed at load), so it was invisible to a module built
878
+ %% through the edit tools, and a harness could not discover it, because a gated
879
+ %% primitive is a handler and `current_predicate/1` reports `no` for handlers.
880
+
881
+ install_builtin_tools(SessionId) :-
882
+ % The marker keeps these out of the harness's own surface — `listing`, the
883
+ % manifest's predicates, the export, and the end_turn probes — while they
884
+ % stay in `session_user_tool_schema/3`, so `task/N` and `with_tools/2` offer
885
+ % them. They are built in, not authored.
886
+ forall(builtin_tool_name(Name),
887
+ ( session_builtin_tool(SessionId, Name)
888
+ -> true
889
+ ; assertz(session_builtin_tool(SessionId, Name))
890
+ )),
891
+ % Idempotent: `parse_clauses/2` runs on every load, and a duplicate schema
892
+ % would show the model the same tool twice.
893
+ ( session_user_tool_schema(SessionId, render_form, _)
894
+ -> true
895
+ ; forall(builtin_tool_clause(Clause), process_clause(Clause, SessionId))
896
+ ).
897
+
898
+ builtin_tool_name(render_form).
899
+ builtin_tool_name(update_form).
900
+ builtin_tool_name(set_form_issues).
901
+ builtin_tool_name(wait_for_form_change).
902
+ builtin_tool_name(validate_form).
903
+ builtin_tool_name(advisories).
904
+ builtin_tool_name(close_form).
905
+
906
+ builtin_tool_clause(
907
+ (tool(render_form(Spec, FormId),
908
+ "Open (or replace) a form. `Spec` is the form spec (version, title, fields, actions). Returns the form id.",
909
+ [object])
910
+ :- form(Spec, FormId))).
911
+ builtin_tool_clause(
912
+ (tool(update_form(FormId, Patch, Result),
913
+ "Update form values with a JSON merge patch.",
914
+ [string, object])
915
+ :- form_update(FormId, Patch), Result = ok)).
916
+ builtin_tool_clause(
917
+ (tool(set_form_issues(FormId, Issues, Result),
918
+ "Attach per-field errors/warnings. Issues is a list of {path, level, message}.",
919
+ [string, array])
920
+ :- form_errors(FormId, Issues), Result = ok)).
921
+ builtin_tool_clause(
922
+ (tool(wait_for_form_change(FormId, Change),
923
+ "Wait for the user to act on a form. Change is a dict with action, values, revision and changed.",
924
+ [string])
925
+ :- await_form(FormId, Change))).
926
+ builtin_tool_clause(
927
+ (tool(validate_form(Values, Result),
928
+ "Hard-validate values without submitting. Result is a list of issues.",
929
+ [object])
930
+ :- get_session_id(SessionId),
931
+ form_validate_values(SessionId, Values, Result))).
932
+ builtin_tool_clause(
933
+ (tool(advisories(Values, Result),
934
+ "Advisory check of values (never blocks). Result is a list of issues.",
935
+ [object])
936
+ :- get_session_id(SessionId),
937
+ form_advisory_values(SessionId, Values, Result))).
938
+ builtin_tool_clause(
939
+ (tool(close_form(FormId, Result), "Close a form.", [string])
940
+ :- close_form(FormId), Result = ok)).
941
+
942
+ %% form_validate_values(+SessionId, +Values, -IssueDicts)
943
+ %% Runs the harness's hard rules if it defines `form_validate/2`, and normalises
944
+ %% whatever it returns (`error/2`, `warning/2`, or an issue dict) into issue
945
+ %% dicts. With no such predicate, there are no hard errors.
946
+ form_validate_values(SessionId, Values, IssueDicts) :-
947
+ ( catch((call(SessionId:form_validate(Values, Issues)), is_list(Issues)), _, fail)
948
+ -> form_issue_dicts(Issues, IssueDicts)
949
+ ; IssueDicts = []
950
+ ).
951
+
952
+ %% repl_normalize_message(+Text, -TermText)
953
+ %% The host `call`/`/call` entry takes the request as a term *as text*. Prose is
954
+ %% not a term, and interpolating it into `request(From, Ref, Message)` produced a
955
+ %% goal the service could not parse — so it never replied and the caller waited
956
+ %% out the timeout. Anything that does not parse is quoted as a string; a valid
957
+ %% term passes through unchanged.
958
+ repl_normalize_message(Text, TermText) :-
959
+ ( catch((term_string(_Term, Text, []), true), _, fail)
960
+ -> TermText = Text
961
+ ; format(string(TermText), "~q", [Text])
962
+ ).
963
+
964
+ %% form_advisory_values(+SessionId, +Values, -IssueDicts)
965
+ %% The advisory twin of `form_validate_values/3`: reads the harness's
966
+ %% `form_advisories/2` when it defines one. Never blocks anything.
967
+ form_advisory_values(SessionId, Values, IssueDicts) :-
968
+ ( catch((call(SessionId:form_advisories(Values, Issues)), is_list(Issues)), _, fail)
969
+ -> form_issue_dicts(Issues, IssueDicts)
970
+ ; IssueDicts = []
971
+ ).
972
+
805
973
  %% process_directive(require_sandbox, +SessionId)
806
974
  %% The harness declares that it must run in an execution sandbox, and the load
807
975
  %% fails if it cannot. Checked against the **effective** backend rather than
@@ -839,6 +1007,10 @@ repl_benign_directive(discontiguous(_)).
839
1007
  repl_benign_directive(discontiguous(_, _)).
840
1008
  repl_benign_directive(multifile(_)).
841
1009
  repl_benign_directive(multifile(_, _)).
1010
+ %% The capability gate was removed — every host feature is always available. An
1011
+ %% older file that still declares one loads unchanged; the directive is ignored
1012
+ %% rather than reported as a mystery "unhandled directive".
1013
+ repl_benign_directive(capability(_)).
842
1014
 
843
1015
  %% sandbox_kind_atom(+Raw, -Atom)
844
1016
  %% A JS string or an atom in, an atom out.
@@ -878,6 +1050,7 @@ repl_unhandled_directives(SessionId, Directives) :-
878
1050
  %% The engine is registered as the session's default `query` frame so that
879
1051
  %% step_engine/4 keeps working as a thin wrapper over step_frame/4.
880
1052
  create_engine(SessionId, _MemoryId, Args, Params, InitialMemory, Engine) :-
1053
+ release_stale_resources(SessionId),
881
1054
  assertz(session_params(SessionId, Params)),
882
1055
  determine_agent_goal(SessionId, Args, Goal),
883
1056
  % Check if tracing is enabled and store in session state
@@ -1032,7 +1205,8 @@ create_session(SessionId, Params, HarnessMemory, MetaMemory) :-
1032
1205
  ),
1033
1206
  retractall(session_sandbox_backend(SessionId, _)),
1034
1207
  assertz(session_sandbox_backend(SessionId, SandboxKind)),
1035
- repl_inject_workspace_predicates(SessionId).
1208
+ repl_inject_workspace_predicates(SessionId),
1209
+ install_builtin_tools(SessionId).
1036
1210
 
1037
1211
  %% repl_abolish_user_predicates(+SessionId)
1038
1212
  %% Removes only the harness's own predicates; imported builtins are untouched.
@@ -1070,7 +1244,9 @@ clear_session(SessionId) :-
1070
1244
  retractall(repl_snapshot(SessionId, _)),
1071
1245
  retractall(session_user_tools(SessionId, _)),
1072
1246
  retractall(session_user_tool_schema(SessionId, _, _)),
1073
- repl_abolish_user_predicates(SessionId).
1247
+ retractall(session_builtin_tool(SessionId, _)),
1248
+ repl_abolish_user_predicates(SessionId),
1249
+ install_builtin_tools(SessionId).
1074
1250
 
1075
1251
  %% destroy_session(+SessionId)
1076
1252
  %% Tears a REPL session down completely: clears the module/frames and removes
@@ -1147,7 +1323,8 @@ repl_initial_state(SessionId, FrameKind, InitialState) :-
1147
1323
  %% step_engine/4 and the runner can drive it.
1148
1324
  query_start(SessionId, Text, FrameKind, InitialState, Error) :-
1149
1325
  catch(
1150
- ( read_repl_goal(SessionId, Text, Goal, Names),
1326
+ ( release_stale_resources(SessionId),
1327
+ read_repl_goal(SessionId, Text, Goal, Names),
1151
1328
  create_frame_raw(SessionId, FrameKind, 0,
1152
1329
  deepclause_mi:repl_solve(Goal, Names, SessionId, InitialState),
1153
1330
  FrameId),
@@ -1227,8 +1404,16 @@ repl_solve(Goal, Names, SessionId, State) :-
1227
1404
  nb_setval(current_session_id, SessionId),
1228
1405
  catch(
1229
1406
  repl_solve_loop(Goal, Names, SessionId, State),
1230
- '$answer_commit',
1231
- true
1407
+ Error,
1408
+ % `answer/1` and `suspend/1` both stop enumeration, and both have already
1409
+ % yielded to the host (the answer text, or the continuation). Anything else
1410
+ % is a real error.
1411
+ ( Error == '$answer_commit'
1412
+ -> true
1413
+ ; Error == '$suspend_commit'
1414
+ -> true
1415
+ ; throw(Error)
1416
+ )
1232
1417
  ).
1233
1418
 
1234
1419
  repl_solve_loop(Goal, Names, SessionId, State) :-
@@ -1345,6 +1530,13 @@ repl_user_predicate(_SessionId, Name, _Arity) :-
1345
1530
  history_predicate(Name),
1346
1531
  !,
1347
1532
  fail.
1533
+ %% Runtime-installed tools (the built-in form tools) are visible to `task/N` but
1534
+ %% are not part of the harness: they do not appear in `listing`, the manifest's
1535
+ %% predicates, the exported clauses, or the counts.
1536
+ repl_user_predicate(SessionId, Name, _Arity) :-
1537
+ session_builtin_tool(SessionId, Name),
1538
+ !,
1539
+ fail.
1348
1540
  repl_user_predicate(SessionId, Name, Arity) :-
1349
1541
  % User DML clauses are asserted (interpreted) into the session module.
1350
1542
  % Exclude predicates imported via use_module (e.g. library(clpfd)) and
@@ -1357,6 +1549,7 @@ repl_user_predicate(SessionId, Name, Arity) :-
1357
1549
 
1358
1550
  internal_session_predicate(param).
1359
1551
  internal_session_predicate(param_decl).
1552
+ internal_session_predicate(tool).
1360
1553
  internal_session_predicate(tool_source).
1361
1554
  internal_session_predicate('__session__').
1362
1555
  internal_session_predicate(Name) :- workspace_predicate_name(Name).
@@ -1513,7 +1706,14 @@ export_directives(SessionId) :-
1513
1706
  ).
1514
1707
 
1515
1708
  export_tools(SessionId) :-
1516
- findall(Name, session_user_tool_schema(SessionId, Name, _), Names0),
1709
+ % Built-in tools are re-installed on load (install_builtin_tools/1), so they
1710
+ % are not written into the harness — an export carries the harness, not the
1711
+ % runtime.
1712
+ findall(Name,
1713
+ ( session_user_tool_schema(SessionId, Name, _),
1714
+ \+ session_builtin_tool(SessionId, Name)
1715
+ ),
1716
+ Names0),
1517
1717
  sort(Names0, Names),
1518
1718
  forall(member(Name, Names),
1519
1719
  ( SessionId:tool_source(Name, Source)
@@ -1555,7 +1755,11 @@ export_stats(SessionId, Clauses, Tools, Scripts) :-
1555
1755
  ),
1556
1756
  PredCounts),
1557
1757
  sum_list(PredCounts, Clauses),
1558
- findall(Name, session_user_tool_schema(SessionId, Name, _), ToolNames0),
1758
+ findall(Name,
1759
+ ( session_user_tool_schema(SessionId, Name, _),
1760
+ \+ session_builtin_tool(SessionId, Name)
1761
+ ),
1762
+ ToolNames0),
1559
1763
  sort(ToolNames0, ToolNames),
1560
1764
  length(ToolNames, Tools),
1561
1765
  ( current_predicate(SessionId:script_file/2)
@@ -1572,7 +1776,22 @@ export_clause(Head, Body) :-
1572
1776
  %% format_tool_source(+Head, +Description, +Body, -SourceCode)
1573
1777
  %% Source text for a tool, used by `listing(tools)` and export. Reverses the
1574
1778
  %% load-time task/prompt rewrites so readers see `task/N`, not `task_named/3`.
1575
- format_tool_source(Head, none, Body, SourceCode) :-
1779
+ %% Render a tool clause back to source for `listing`/`export`.
1780
+ format_tool_source(Head, Description, Body, SourceCode) :-
1781
+ format_tool_source(Head, Description, none, Body, SourceCode).
1782
+
1783
+ %% With an explicit input-type list: `tool(Head, Desc, [object]) :- Body.`
1784
+ format_tool_source(Head, Description, Types, Body, SourceCode) :-
1785
+ Types \== none,
1786
+ !,
1787
+ copy_term(Head-Description-Types-Body, NH-ND-NT-NB),
1788
+ numbervars(NH-ND-NT-NB, 0, _),
1789
+ clean_clause_body(NB, CleanBody),
1790
+ ( CleanBody == true
1791
+ -> format(string(SourceCode), "tool(~q, ~q, ~q).", [NH, ND, NT])
1792
+ ; format(string(SourceCode), "tool(~q, ~q, ~q) :-~n ~q.", [NH, ND, NT, CleanBody])
1793
+ ).
1794
+ format_tool_source(Head, none, _Types, Body, SourceCode) :-
1576
1795
  !,
1577
1796
  copy_term(Head-Body, NH-NB),
1578
1797
  numbervars(NH-NB, 0, _),
@@ -1581,7 +1800,7 @@ format_tool_source(Head, none, Body, SourceCode) :-
1581
1800
  -> format(string(SourceCode), "tool(~q).", [NH])
1582
1801
  ; format(string(SourceCode), "tool(~q) :-~n ~q.", [NH, CleanBody])
1583
1802
  ).
1584
- format_tool_source(Head, Description, Body, SourceCode) :-
1803
+ format_tool_source(Head, Description, _Types, Body, SourceCode) :-
1585
1804
  copy_term(Head-Description-Body, NH-ND-NB),
1586
1805
  numbervars(NH-ND-NB, 0, _),
1587
1806
  clean_clause_body(NB, CleanBody),
@@ -1850,6 +2069,7 @@ repl_assert_many(SessionId, ClausesText, Result) :-
1850
2069
  %% have no plain indicator here (process_clause owns them).
1851
2070
  repl_term_indicator((:- _), _) :- !, fail.
1852
2071
  repl_term_indicator((tool(_, _) :- _), _) :- !, fail.
2072
+ repl_term_indicator((tool(_, _, _) :- _), _) :- !, fail.
1853
2073
  repl_term_indicator((tool(_) :- _), _) :- !, fail.
1854
2074
  repl_term_indicator(Term, Name/Arity) :-
1855
2075
  repl_head_of(Term, Head),
@@ -2488,6 +2708,9 @@ tool_contract_violation(SessionId, Warning) :-
2488
2708
  clause(SessionId:tool(Head), Body),
2489
2709
  compound(Head),
2490
2710
  Head =.. [Name|HeadArgs],
2711
+ % Built-in tools call host handlers (`form/2`, …), which are not session
2712
+ % predicates by design — they are not the author's to define.
2713
+ \+ session_builtin_tool(SessionId, Name),
2491
2714
  length(HeadArgs, Arity),
2492
2715
  tool_body_goal(Body, Call),
2493
2716
  Call =.. [Callee|Args],
@@ -2500,12 +2723,23 @@ tool_contract_violation(SessionId, Warning) :-
2500
2723
  -> format(string(Warning),
2501
2724
  '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
2725
  [Name, Arity, Callee, CalleeArity])
2503
- ; \+ ( SessionId:current_predicate(Callee/CalleeArity) )
2726
+ ; \+ ( SessionId:current_predicate(Callee/CalleeArity) ; tool_host_predicate(Callee, CalleeArity) )
2504
2727
  -> format(string(Warning),
2505
2728
  '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
2729
  [Name, Arity, Callee, CalleeArity, Callee, CalleeArity])
2507
2730
  ).
2508
2731
 
2732
+ %% Host handlers (service_call/3, form/2, exec/2, …) are mi_call specials, not
2733
+ %% session clauses. A tool that wraps one is valid even though
2734
+ %% current_predicate/1 reports false.
2735
+ tool_host_predicate(Name, Arity) :-
2736
+ atom(Name),
2737
+ Name \== (:),
2738
+ integer(Arity),
2739
+ Arity >= 0,
2740
+ functor(Goal, Name, Arity),
2741
+ is_mi_special_predicate(Goal).
2742
+
2509
2743
  %% tool_body_goal(+Body, -FirstCall): a tool body must reach a call, not be empty.
2510
2744
  tool_body_goal(Body, Call) :-
2511
2745
  Body \== true,
@@ -2990,9 +3224,14 @@ step_engine(SessionId, Status, Content, Payload) :-
2990
3224
  %% process_engine_result(+Result, -Status, -Content, -Payload)
2991
3225
  process_engine_result(output(Text), output, Text, none) :- !.
2992
3226
  process_engine_result(media(Payload), media, '', Payload) :- is_dict(Payload), !.
3227
+ process_engine_result(html(Payload), html, '', Payload) :- is_dict(Payload), !.
2993
3228
  process_engine_result(log(Text), log, Text, none) :- !.
3229
+ process_engine_result(suspend(Continuation, Memory), suspend, '', payload{continuation: Continuation, memory: Memory}) :- !.
2994
3230
  process_engine_result(answer(Text), answer, Text, none) :- !.
2995
3231
  %% Current 8-arg format (with model spec from with_model/2)
3232
+ %% Conversation: the same loop with `mode: chat` (no finish, ends when the model stops).
3233
+ process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec, Mode), request_agent_loop, '',
3234
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec, mode: Mode}) :- !.
2996
3235
  process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec), request_agent_loop, '',
2997
3236
  payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec}) :- !.
2998
3237
  %% Prompt-family 6-arg format (tool scope + model spec; no reasoning/recipe)
@@ -3014,6 +3253,20 @@ process_engine_result(request_exec(Tool, Args), request_exec, '',
3014
3253
  process_engine_result(request_proc(Request), request_proc, '', Payload) :-
3015
3254
  proc_request_payload(Request, Payload).
3016
3255
 
3256
+ %% Forms (docs/DML_FORMS.md). The host owns the registry; these carry the
3257
+ %% operation and, for `wait_form`, the change to deliver.
3258
+ %% Conversation inbox: the host answers with the next item (see wait_next_item/3).
3259
+ process_engine_result(wait_next(Accept), wait_next, '', payload{accept: Accept}) :- !.
3260
+ process_engine_result(request_form(Op, Spec), request_form, '', payload{op: Op, spec: Spec}) :- !.
3261
+ process_engine_result(request_form(update, FormId, Patch), request_form, '',
3262
+ payload{op: update, formId: FormId, patch: Patch}) :- !.
3263
+ process_engine_result(request_form(errors, FormId, Issues), request_form, '',
3264
+ payload{op: errors, formId: FormId, issues: Issues}) :- !.
3265
+ process_engine_result(request_form(close, FormId), request_form, '',
3266
+ payload{op: close, formId: FormId}) :- !.
3267
+ process_engine_result(wait_form(FormId, Since, Timeout), wait_form, '',
3268
+ payload{formId: FormId, since: Since, timeout: Timeout}) :- !.
3269
+
3017
3270
  %% proc_request_payload(+Request, -Payload)
3018
3271
  proc_request_payload(self, payload{op: self}) :- !.
3019
3272
  proc_request_payload(spawn(Goal), payload{op: spawn, goal: Goal}) :- !.
@@ -3102,12 +3355,17 @@ destroy_engine(SessionId) :-
3102
3355
  post_agent_result(SessionId, Success, Variables, Messages) :-
3103
3356
  post_agent_result(SessionId, Success, Variables, Messages, none).
3104
3357
 
3105
- %% post_agent_result(+SessionId, +Success, +Variables, +Messages, +FullMemory)
3358
+ post_agent_result(SessionId, Success, Variables, Messages, FullMemory) :-
3359
+ post_agent_result(SessionId, Success, Variables, Messages, FullMemory, "").
3360
+
3361
+ %% post_agent_result(+SessionId, +Success, +Variables, +Messages, +FullMemory, +Error)
3106
3362
  %% Messages is a list of message{role: Role, content: Content} dicts.
3107
3363
  %% FullMemory optionally replaces the entire state memory, including compacted system summaries.
3108
- post_agent_result(SessionId, Success, Variables, Messages, FullMemory) :-
3364
+ %% Error is non-empty when the turn failed before producing a result (a model
3365
+ %% error), so a caller can say what went wrong instead of guessing.
3366
+ post_agent_result(SessionId, Success, Variables, Messages, FullMemory, Error) :-
3109
3367
  retractall(session_agent_result(SessionId, _)),
3110
- assertz(session_agent_result(SessionId, result{success: Success, variables: Variables, messages: Messages, full_memory: FullMemory})),
3368
+ assertz(session_agent_result(SessionId, result{success: Success, variables: Variables, messages: Messages, full_memory: FullMemory, error: Error})),
3111
3369
  post_signal_to_engine(SessionId, agent_done).
3112
3370
 
3113
3371
  %% post_exec_result(+SessionId, +Status, +Result)
@@ -3123,6 +3381,22 @@ post_proc_result(SessionId, Key, Value) :-
3123
3381
  assertz(session_proc_result(SessionId, Key, Value)),
3124
3382
  post_signal_to_engine(SessionId, proc_done).
3125
3383
 
3384
+ %% post_form_result(+SessionId, +Result)
3385
+ %% Reply to a forms primitive (`form/2`, `form_update/2`, `form_errors/2`,
3386
+ %% `await_form/2,3`, `close_form/1`). Result is `ok(...)` or `error(...)`.
3387
+ %% post_next_result(+SessionId, +Item)
3388
+ %% The host answers `wait_next` with `user("...")`, `message(Term)`, or `closed`
3389
+ %% when there is nobody left to talk to.
3390
+ post_next_result(SessionId, Item) :-
3391
+ retractall(session_next_result(SessionId, _)),
3392
+ assertz(session_next_result(SessionId, Item)),
3393
+ post_signal_to_engine(SessionId, next_done).
3394
+
3395
+ post_form_result(SessionId, Result) :-
3396
+ retractall(session_form_result(SessionId, _)),
3397
+ assertz(session_form_result(SessionId, Result)),
3398
+ post_signal_to_engine(SessionId, form_done).
3399
+
3126
3400
  %% post_sample_token_result(+SessionId, +Status, +TokenOrError)
3127
3401
  post_sample_token_result(SessionId, Status, TokenOrError) :-
3128
3402
  retractall(session_sample_token_result(SessionId, _)),
@@ -3179,6 +3453,8 @@ mi(Goal, StateIn, SessionId) :-
3179
3453
  Error,
3180
3454
  ( Error == '$answer_commit'
3181
3455
  -> true % answer/1 threw to commit - success, no backtracking
3456
+ ; Error == '$suspend_commit'
3457
+ -> true % suspend/1 threw to stop here; the host holds the continuation
3182
3458
  ; Error == gas_exhausted
3183
3459
  -> engine_yield(error("Gas exhausted: execution exceeded iteration limit")),
3184
3460
  fail
@@ -3511,9 +3787,22 @@ create_tool_engine(SessionId, ToolName, Args, ToolEngineId) :-
3511
3787
  Goal = (
3512
3788
  nb_setval(current_session_id, SessionId),
3513
3789
  catch(
3514
- (mi_call(Body, InitialState, _FinalState),
3515
- extract_tool_result_simple(TotalArity, InputArity, HeadArgs, ToolResult),
3516
- engine_yield(tool_finished(ToolResult))),
3790
+ ( mi_call(Body, InitialState, _FinalState),
3791
+ % A tool that declares an output but leaves it unbound has no
3792
+ % result. `extract_tool_result_simple/4` maps that to `true`, which
3793
+ % is how a `tool(fs_write(Path, Content), …)` (content taken for the
3794
+ % output) reported success while writing nothing.
3795
+ ( TotalArity > InputArity,
3796
+ last(HeadArgs, LastArg),
3797
+ var(LastArg)
3798
+ -> format(atom(NoResult),
3799
+ '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.',
3800
+ [ToolNameAtom]),
3801
+ engine_yield(tool_no_result(NoResult))
3802
+ ; extract_tool_result_simple(TotalArity, InputArity, HeadArgs, ToolResult),
3803
+ engine_yield(tool_finished(ToolResult))
3804
+ )
3805
+ ),
3517
3806
  Error,
3518
3807
  (handle_tool_engine_error(Error, ErrResult),
3519
3808
  engine_yield(tool_finished(ErrResult)))
@@ -3528,6 +3817,9 @@ create_tool_engine(SessionId, ToolName, Args, ToolEngineId) :-
3528
3817
 
3529
3818
  %% handle_tool_engine_error(+Error, -Result)
3530
3819
  handle_tool_engine_error('$answer_commit', true) :- !.
3820
+ %% A tool body that suspends has already handed its continuation to the host; the
3821
+ %% tool engine stops with it rather than turning it into a tool failure.
3822
+ handle_tool_engine_error('$suspend_commit', suspended) :- !.
3531
3823
  handle_tool_engine_error(Error, error{message: ErrMsg}) :-
3532
3824
  format(atom(ErrMsg), '~w', [Error]).
3533
3825
 
@@ -3557,6 +3849,9 @@ step_tool_engine(ToolEngineId, Status, Content, Payload) :-
3557
3849
 
3558
3850
  %% process_tool_engine_result(+Result, -Status, -Content, -Payload)
3559
3851
  process_tool_engine_result(tool_finished(ToolResult), finished, '', payload{result: ToolResult}) :- !.
3852
+ %% A tool whose output argument was never bound: surface it as an error, not a
3853
+ %% silent success (see create_tool_engine/4).
3854
+ process_tool_engine_result(tool_no_result(Message), error, Message, none) :- !.
3560
3855
  process_tool_engine_result(request_exec(Tool, Args), request_exec, '',
3561
3856
  payload{toolName: Tool, args: Args}) :- !.
3562
3857
  process_tool_engine_result(request_sample_token(Prompt, Allowed), request_sample_token, '',
@@ -3564,6 +3859,14 @@ process_tool_engine_result(request_sample_token(Prompt, Allowed), request_sample
3564
3859
  process_tool_engine_result(request_llm(Messages), request_llm, '',
3565
3860
  payload{messages: Messages}) :- !.
3566
3861
  process_tool_engine_result(request_judgment(Payload), request_judgment, '', Payload) :- !.
3862
+ process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec, Mode), request_agent_loop, '',
3863
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec, mode: Mode}) :- !.
3864
+ %% task/N from inside a tool: includes the model spec.
3865
+ process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec), request_agent_loop, '',
3866
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec}) :- !.
3867
+ %% prompt/N from inside a tool: no tools and no memory, but carries the model spec.
3868
+ process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ModelSpec), request_agent_loop, '',
3869
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: none, recipeContext: "", modelSpec: ModelSpec}) :- !.
3567
3870
  process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext), request_agent_loop, '',
3568
3871
  payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext}) :- !.
3569
3872
  %% Legacy 5-arg format (no reasoning/recipe)
@@ -3576,6 +3879,17 @@ process_tool_engine_result(request_agent_loop(Desc, Vars, Tools, Memory), reques
3576
3879
  %% are yielded to the host exactly as in the main engine.
3577
3880
  process_tool_engine_result(request_proc(Request), request_proc, '', Payload) :-
3578
3881
  proc_request_payload(Request, Payload).
3882
+ %% Form primitives reached inside a DML tool (docs/DML_FORMS.md).
3883
+ process_tool_engine_result(wait_next(Accept), wait_next, '', payload{accept: Accept}) :- !.
3884
+ process_tool_engine_result(request_form(Op, Spec), request_form, '', payload{op: Op, spec: Spec}) :- !.
3885
+ process_tool_engine_result(request_form(update, FormId, Patch), request_form, '',
3886
+ payload{op: update, formId: FormId, patch: Patch}) :- !.
3887
+ process_tool_engine_result(request_form(errors, FormId, Issues), request_form, '',
3888
+ payload{op: errors, formId: FormId, issues: Issues}) :- !.
3889
+ process_tool_engine_result(request_form(close, FormId), request_form, '',
3890
+ payload{op: close, formId: FormId}) :- !.
3891
+ process_tool_engine_result(wait_form(FormId, Since, Timeout), wait_form, '',
3892
+ payload{formId: FormId, since: Since, timeout: Timeout}) :- !.
3579
3893
  process_tool_engine_result(output(Text), output, Text, none) :- !.
3580
3894
  process_tool_engine_result(log(Text), log, Text, none) :- !.
3581
3895
  process_tool_engine_result(Other, error, Msg, none) :-
@@ -3774,6 +4088,112 @@ mi_call(prompt_named(Desc, Vars, VarNames), StateIn, StateOut) :-
3774
4088
  consume_gas(StateIn, State1),
3775
4089
  mi_call_prompt_n(Desc, Vars, VarNames, State1, StateOut).
3776
4090
 
4091
+ %% ============================================================
4092
+ %% Conversation (docs/DML_CONVERSATION.md)
4093
+ %% ============================================================
4094
+
4095
+ %% mi_call(input(-Message), +StateIn, -StateOut)
4096
+ %% Wait for the person's next message. No prompt: the embedding UI is a chat
4097
+ %% interface and is already asking. This is `next/1` restricted to the person —
4098
+ %% a worker's message that arrives first stays queued for `next/1`. FAILS when
4099
+ %% there is nobody to talk to, which is what ends
4100
+ %% `loop :- input(A), chat(A).`
4101
+ mi_call(input(Message), StateIn, StateIn) :-
4102
+ !,
4103
+ get_session_id(SessionId),
4104
+ get_depth(StateIn, Depth),
4105
+ wait_next_item(SessionId, user, Item),
4106
+ ( Item = user(Message)
4107
+ -> add_trace_with_result(SessionId, exit, input, [""], Message, Depth)
4108
+ ; fail
4109
+ ).
4110
+
4111
+ %% mi_call(next(-Item), +StateIn, -StateOut)
4112
+ %% The next item from the conversation inbox: `user(Text)` from the person, or a
4113
+ %% message a worker sent to this session. Fails when nothing is left, so
4114
+ %% `loop :- next(Item), handle(Item), loop.` ends cleanly.
4115
+ mi_call(next(Item), StateIn, StateIn) :-
4116
+ !,
4117
+ get_session_id(SessionId),
4118
+ wait_next_item(SessionId, any, Item),
4119
+ Item \== closed.
4120
+
4121
+ %% wait_next_item(+SessionId, +Accept, -Item)
4122
+ %% One host round trip: the runner owns the inbox (it is the only place that can
4123
+ %% see both the person and the mailbox), and answers with the next item.
4124
+ wait_next_item(SessionId, Accept, Item) :-
4125
+ engine_yield(wait_next(Accept)),
4126
+ get_next_signal(SessionId, _),
4127
+ session_next_result(SessionId, Item),
4128
+ retractall(session_next_result(SessionId, _)).
4129
+
4130
+ %% mi_call(chat(+Message), +StateIn, -StateOut) / chat(+Message, -Reply)
4131
+ %%
4132
+ %% One conversational turn: `Message` is the person's message, the agent loop runs
4133
+ %% with the harness's tools, and the turn ends when the model stops calling tools.
4134
+ %% No `finish`, no `set_result` and no nudging — the reply *is* the answer
4135
+ %% (agent.ts `conversational`). Memory carries the conversation forward, so
4136
+ %% `loop :- input(A), chat(A).` is a conversation in one run.
4137
+ mi_call(chat(Message), StateIn, StateOut) :-
4138
+ !,
4139
+ mi_call(chat(Message, _Reply), StateIn, StateOut).
4140
+
4141
+ mi_call(chat(Message, Reply), StateIn, StateOut) :-
4142
+ !,
4143
+ consume_gas(StateIn, State0),
4144
+ get_params(State0, Params),
4145
+ interpolate_desc(Message, Params, InterpMessage),
4146
+ get_session_id(SessionId),
4147
+ get_depth(StateIn, Depth),
4148
+ add_trace_entry(SessionId, llm_call, chat, [InterpMessage], Depth),
4149
+ collect_user_tools(SessionId, UserTools),
4150
+ % The message is NOT added to memory here: the loop appends it as the turn's
4151
+ % user message, and returns the whole conversation as `full_memory`.
4152
+ get_memory(State0, Memory0),
4153
+ get_tool_scope(State0, ToolScope),
4154
+ get_reasoning_effort(State0, ReasoningEffort),
4155
+ get_recipe_context(State0, RecipeContext),
4156
+ get_model_spec(State0, ModelSpec),
4157
+ engine_yield(request_agent_loop(InterpMessage, [], UserTools, Memory0, ToolScope,
4158
+ ReasoningEffort, RecipeContext, ModelSpec, chat)),
4159
+ handle_agent_signals(SessionId, State0, StateAfterAgent),
4160
+ session_agent_result(SessionId, Result),
4161
+ retract(session_agent_result(SessionId, _)),
4162
+ % A conversation must survive a failed turn: say so, leave the memory as it
4163
+ % was (the failed turn is not part of the conversation), and let the person
4164
+ % type again. Failing here would end `loop :- input(A), chat(A), loop.` and
4165
+ % drop them out of the chat.
4166
+ ( get_dict(success, Result, true)
4167
+ -> ( get_dict(full_memory, Result, FullMemory), is_list(FullMemory)
4168
+ -> set_memory(StateAfterAgent, FullMemory, StateOut)
4169
+ ; set_memory(StateAfterAgent, Memory0, StateOut)
4170
+ ),
4171
+ last_assistant_message(Result, Reply),
4172
+ add_trace_with_result(SessionId, exit, chat, [InterpMessage], Reply, Depth)
4173
+ ; ( get_dict(error, Result, ErrorMsg), ErrorMsg \= ""
4174
+ -> format(string(WarnMsg),
4175
+ 'The model call failed (~w). Your message was not answered — try again.', [ErrorMsg])
4176
+ ; WarnMsg = 'The model call failed. Your message was not answered — try again.'
4177
+ ),
4178
+ engine_yield(output(WarnMsg)),
4179
+ Reply = "",
4180
+ set_memory(StateAfterAgent, Memory0, StateOut),
4181
+ add_trace_entry(SessionId, fail, chat, [InterpMessage], Depth)
4182
+ ).
4183
+
4184
+ %% last_assistant_message(+AgentResult, -Text)
4185
+ %% The reply is also streamed to the UI; this binds it for a harness that wants it.
4186
+ last_assistant_message(Result, Text) :-
4187
+ ( get_dict(full_memory, Result, Messages), is_list(Messages)
4188
+ -> true
4189
+ ; Messages = []
4190
+ ),
4191
+ findall(Content, member(message{role: assistant, content: Content}, Messages), Texts),
4192
+ ( last(Texts, Text)
4193
+ -> true
4194
+ ; Text = ""
4195
+ ).
4196
+
3777
4197
  %% mi_call_prompt_n(+Desc, +Vars, +VarNames, +StateIn, -StateOut)
3778
4198
  %% Simple LLM call with empty memory and NO tools (pure text completion with variable binding)
3779
4199
  mi_call_prompt_n(Desc, Vars, VarNames, StateIn, StateOut) :-
@@ -4299,7 +4719,7 @@ mi_call(spawn_opt(Goal, Pid, Options), StateIn, StateIn) :-
4299
4719
  get_session_id(SessionId),
4300
4720
  copy_term(Goal, GoalCopy),
4301
4721
  term_string(GoalCopy, GoalText),
4302
- ( is_dict(Options) -> term_string(Options, OptionsText) ; OptionsText = "{}" ),
4722
+ spawn_options_text(Options, OptionsText),
4303
4723
  engine_yield(request_proc(spawn(GoalText, OptionsText))),
4304
4724
  get_next_signal(SessionId, _),
4305
4725
  session_proc_result(SessionId, spawn, SpawnResult),
@@ -4309,6 +4729,39 @@ mi_call(spawn_opt(Goal, Pid, Options), StateIn, StateIn) :-
4309
4729
  ; throw(error(process_spawn_failed(SpawnResult), spawn_opt/3))
4310
4730
  ).
4311
4731
 
4732
+ %% spawn_options_text(+Options, -Text)
4733
+ %% `spawn_opt/3` takes an options **dict** (`_{name: worker}`), but the obvious
4734
+ %% Prolog spelling is a list (`[name(worker)]`) or a single option
4735
+ %% (`name(worker)`). A non-dict used to be silently ignored, so the process kept
4736
+ %% its default label and nothing said so — the agent's `name(tstsvc)` did nothing,
4737
+ %% and two of this repo's own tests pass a list and believe they named a worker.
4738
+ spawn_options_text(Options, Text) :-
4739
+ ( Options == []
4740
+ -> Text = "{}"
4741
+ ; is_dict(Options)
4742
+ -> term_string(Options, Text)
4743
+ ; ( is_list(Options) -> List = Options ; List = [Options] ),
4744
+ spawn_option_pairs(List, Pairs),
4745
+ dict_pairs(Dict, _, Pairs),
4746
+ term_string(Dict, Text)
4747
+ ).
4748
+
4749
+ spawn_option_pairs([], []).
4750
+ spawn_option_pairs([Option|Rest], [Key-Value|Pairs]) :-
4751
+ spawn_option(Option, Key, Value),
4752
+ spawn_option_pairs(Rest, Pairs).
4753
+
4754
+ spawn_option(name(V), name, V) :- !.
4755
+ spawn_option(priority(V), priority, V) :- !.
4756
+ spawn_option(timeout(V), timeout, V) :- !.
4757
+ spawn_option(code(V), code, V) :- !.
4758
+ spawn_option(Key-V, Key, V) :- atom(Key), !.
4759
+ spawn_option(Option, _, _) :-
4760
+ format(atom(Message),
4761
+ 'spawn_opt/3 does not understand the option ~w (use name(...), priority(...), timeout(...), code(...), or a dict)',
4762
+ [Option]),
4763
+ throw(error(domain_error(spawn_option, Option), Message)).
4764
+
4312
4765
  %% mi_call(send(+Target, +Message), +StateIn, -StateOut)
4313
4766
  mi_call(send(To, Message), StateIn, StateIn) :-
4314
4767
  !,
@@ -4484,22 +4937,53 @@ mi_call(now_ms(Milliseconds), StateIn, StateIn) :-
4484
4937
  %% mi_call(serve(+Name, +Goal), +StateIn, -StateOut)
4485
4938
  %% Spawn a session-scoped process and register it under Name. Sugar over
4486
4939
  %% spawn/2 + register/2; the goal decides the process's shape.
4940
+ %%
4941
+ %% The process is *labelled* with the service name. A spawn labels a process with
4942
+ %% its goal functor, so `serve(test_service, test_service_loop)` used to appear in
4943
+ %% `ps` as "test_service_loop" — the label of an anonymous loop — and a live
4944
+ %% service looked like something nobody could talk to. `process_info/2` keeps the
4945
+ %% goal, so nothing is lost.
4487
4946
  mi_call(serve(Name, Goal), StateIn, StateIn) :-
4488
4947
  !,
4489
4948
  get_session_id(SessionId),
4490
4949
  copy_term(Goal, GoalCopy),
4491
4950
  term_string(GoalCopy, GoalText),
4492
- engine_yield(request_proc(spawn(GoalText))),
4951
+ ( atom(Name)
4952
+ -> Label = Name
4953
+ ; atom_string(Label, Name) % a string name -> atom
4954
+ ),
4955
+ % Spawn options are read as a dict (`parseSpawnOptions`), so `name:` not `name(...)`.
4956
+ format(string(OptionsText), "{name: ~q}", [Label]),
4957
+ engine_yield(request_proc(spawn(GoalText, OptionsText))),
4493
4958
  get_next_signal(SessionId, _),
4494
4959
  session_proc_result(SessionId, spawn, SpawnResult),
4495
4960
  retract(session_proc_result(SessionId, spawn, _)),
4496
- SpawnResult = ok(Pid),
4961
+ % A failed spawn must be an error, not a silent failure: unifying with ok/1
4962
+ % used to fail, so `serve/2` looked like a predicate with no solutions.
4963
+ ( SpawnResult = ok(Pid)
4964
+ -> true
4965
+ ; throw(error(process_spawn_failed(SpawnResult), serve/2))
4966
+ ),
4497
4967
  term_string(Name, NameText),
4498
4968
  term_string(Pid, PidText),
4499
4969
  engine_yield(request_proc(register(NameText, PidText))),
4500
4970
  get_next_signal(SessionId, _),
4501
- session_proc_result(SessionId, register, _),
4502
- retract(session_proc_result(SessionId, register, _)).
4971
+ session_proc_result(SessionId, register, RegisterResult),
4972
+ retract(session_proc_result(SessionId, register, _)),
4973
+ ( RegisterResult == ok
4974
+ -> true
4975
+ ; throw(error(process_register_failed(RegisterResult), serve/2))
4976
+ ).
4977
+
4978
+ %% mi_call(serve(+Name, +Goal, -Pid), +StateIn, -StateOut)
4979
+ %% `serve/2` registers and binds nothing; the pid is what a caller needs next (to
4980
+ %% `wait`, `stop` or link it), and `spawn/2` already binds one. So this is the same
4981
+ %% call with the pid out — not a third "options" argument (`serve/3` did not exist,
4982
+ %% which is a guess the argument shapes invite).
4983
+ mi_call(serve(Name, Goal, Pid), StateIn, StateOut) :-
4984
+ !,
4985
+ mi_call(serve(Name, Goal), StateIn, State1),
4986
+ mi_call(whereis(Name, Pid), State1, StateOut).
4503
4987
 
4504
4988
  %% mi_call(service_cast(+Target, +Message), +StateIn, -StateOut)
4505
4989
  mi_call(service_cast(Target, Message), StateIn, StateIn) :-
@@ -4842,6 +5326,61 @@ mi_call(answer(Text), StateIn, StateIn) :-
4842
5326
  engine_yield(answer(InterpText)),
4843
5327
  throw('$answer_commit').
4844
5328
 
5329
+ %% mi_call(suspend(+Continuation), +StateIn, -StateOut)
5330
+ %% Stop the turn at a **safe point** and let the host persist a resumable
5331
+ %% continuation (docs/DML_REPL_SUSPEND_RESUME.md).
5332
+ %%
5333
+ %% `Continuation` is the goal that resumes where this left off — the entry point
5334
+ %% of the loop whose state is the argument, e.g.
5335
+ %%
5336
+ %% loop(State) :- ..., checkpoint(State), suspend(loop(State)).
5337
+ %%
5338
+ %% A turn is suspended *between* host calls, never inside one: a model call, a
5339
+ %% shell command or a form wait has already handed control to the host by the time
5340
+ %% this runs. Non-serializable state it did not hand over — an open stream, an open
5341
+ %% form — makes it refuse, so a resumed session can never inherit a half-written
5342
+ %% file.
5343
+ mi_call(suspend(Continuation), StateIn, StateIn) :-
5344
+ !,
5345
+ get_session_id(SessionId),
5346
+ ( session_resource(SessionId, Resource)
5347
+ -> format(atom(Message),
5348
+ 'cannot suspend while holding ~w: close it first', [Resource]),
5349
+ throw(error(suspend_refused(Resource), Message))
5350
+ ; true
5351
+ ),
5352
+ term_string(Continuation, ContinuationText),
5353
+ get_memory(StateIn, Memory),
5354
+ engine_yield(suspend(ContinuationText, Memory)),
5355
+ throw('$suspend_commit').
5356
+
5357
+ %% release_stale_resources(+SessionId)
5358
+ %% A turn that was aborted while holding a stream leaves the handle open and the
5359
+ %% ledger non-empty, which would block every later `suspend/1`. Runs start clean:
5360
+ %% close whatever the previous turn left open and forget it. (A suspension is a
5361
+ %% clean stop, so it never leaves anything behind — `suspend/1` refuses while the
5362
+ %% ledger is non-empty.)
5363
+ release_stale_resources(SessionId) :-
5364
+ forall(session_resource(SessionId, Resource),
5365
+ ( Resource = file(_, Stream)
5366
+ -> catch(close(Stream), _, true)
5367
+ ; true
5368
+ )),
5369
+ retractall(session_resource(SessionId, _)).
5370
+
5371
+ %% acquire_resource(+Resource) / release_resources(+Pattern)
5372
+ %% What a turn holds that a snapshot cannot: a stream or an open form.
5373
+ acquire_resource(Resource) :-
5374
+ get_session_id(SessionId),
5375
+ ( session_resource(SessionId, Resource)
5376
+ -> true
5377
+ ; assertz(session_resource(SessionId, Resource))
5378
+ ).
5379
+
5380
+ release_resources(Pattern) :-
5381
+ get_session_id(SessionId),
5382
+ retractall(session_resource(SessionId, Pattern)).
5383
+
4845
5384
  %% mi_call(output(Media), +StateIn, -StateOut)
4846
5385
  %% Present an image instead of text: `output(media(Path, Mime))`, `output(media(Path))`,
4847
5386
  %% or a dict form (`media{svg: Svg}`, `media{data: Base64, mime: Mime}`). The host
@@ -4865,6 +5404,22 @@ mi_call(output(Media), StateIn, StateIn) :-
4865
5404
  add_trace_entry(SessionId, media, media, [Path], Depth),
4866
5405
  engine_yield(media(media{path: Path, mimeType: Mime, kind: Kind, data: Data})).
4867
5406
 
5407
+ %% mi_call(output(html(Path)), +StateIn, -StateOut)
5408
+ %% Present a workspace HTML file as an interactive miniapp.
5409
+ mi_call(output(html(Path)), StateIn, StateIn) :-
5410
+ nonvar(Path),
5411
+ ( string(Path)
5412
+ -> HtmlPath = Path
5413
+ ; atom(Path)
5414
+ -> atom_string(Path, HtmlPath)
5415
+ ; fail
5416
+ ),
5417
+ get_session_id(SessionId),
5418
+ get_depth(StateIn, Depth),
5419
+ add_trace_entry(SessionId, html, html, [HtmlPath], Depth),
5420
+ !,
5421
+ engine_yield(html(html{path: HtmlPath})).
5422
+
4868
5423
  %% mi_call(output(Text), +StateIn, -StateOut)
4869
5424
  mi_call(output(Text), StateIn, StateIn) :-
4870
5425
  get_params(StateIn, Params),
@@ -4896,6 +5451,115 @@ mi_call(input(Prompt, Input), StateIn, StateIn) :-
4896
5451
  add_trace_with_result(SessionId, exit, input, [InterpPrompt], "", Depth)
4897
5452
  ).
4898
5453
 
5454
+ %% ============================================================
5455
+ %% Forms (docs/DML_FORMS.md)
5456
+ %% ============================================================
5457
+
5458
+ %% mi_call(form(+Spec, -FormId), +StateIn, -StateOut)
5459
+ mi_call(form(Spec, FormId), StateIn, StateIn) :-
5460
+ !,
5461
+ get_session_id(SessionId),
5462
+ engine_yield(request_form(open, Spec)),
5463
+ get_next_signal(SessionId, _),
5464
+ session_form_result(SessionId, Result),
5465
+ retract(session_form_result(SessionId, _)),
5466
+ ( Result = ok(Dict)
5467
+ -> get_dict(formId, Dict, FormId),
5468
+ % An open form is host state a snapshot cannot carry, so `suspend/1`
5469
+ % refuses until it is closed (docs/DML_REPL_SUSPEND_RESUME.md).
5470
+ acquire_resource(form(FormId))
5471
+ ; throw(error(form_open_failed(Result), form/2))
5472
+ ).
5473
+
5474
+ %% mi_call(form_update(+FormId, +Patch), +StateIn, -StateOut)
5475
+ mi_call(form_update(FormId, Patch), StateIn, StateIn) :-
5476
+ !,
5477
+ get_session_id(SessionId),
5478
+ engine_yield(request_form(update, FormId, Patch)),
5479
+ get_next_signal(SessionId, _),
5480
+ session_form_result(SessionId, _), retract(session_form_result(SessionId, _)).
5481
+
5482
+ %% mi_call(form_errors(+FormId, +Issues), +StateIn, -StateOut)
5483
+ %% Issues are `error(Path, Message)` / `warning(Path, Message)` terms.
5484
+ mi_call(form_errors(FormId, Issues), StateIn, StateIn) :-
5485
+ !,
5486
+ form_issue_dicts(Issues, Dicts),
5487
+ get_session_id(SessionId),
5488
+ engine_yield(request_form(errors, FormId, Dicts)),
5489
+ get_next_signal(SessionId, _),
5490
+ session_form_result(SessionId, _), retract(session_form_result(SessionId, _)).
5491
+
5492
+ %% mi_call(close_form(+FormId), +StateIn, -StateOut)
5493
+ mi_call(close_form(FormId), StateIn, StateIn) :-
5494
+ !,
5495
+ release_resources(form(FormId)),
5496
+ get_session_id(SessionId),
5497
+ engine_yield(request_form(close, FormId)),
5498
+ get_next_signal(SessionId, _),
5499
+ session_form_result(SessionId, _), retract(session_form_result(SessionId, _)).
5500
+
5501
+ %% mi_call(await_form(+FormId, -Change) / await_form(+FormId, +Since, -Change))
5502
+ %% The 2-arity form waits for the next change (the registry's cursor).
5503
+ %%
5504
+ %% A submit the host's rules reject never reaches the caller: the host returns
5505
+ %% `action: invalid` with the errors, and this loops — attaching them to the form
5506
+ %% so the user sees what is wrong, then waiting for the next attempt. So a harness
5507
+ %% (or a model's `wait_for_form_change`) only ever sees an action the rules
5508
+ %% accepted, and neither has to write the reject-and-re-show loop.
5509
+ mi_call(await_form(FormId, Change), StateIn, StateIn) :-
5510
+ !,
5511
+ mi_call(await_form(FormId, -1, Change), StateIn, StateIn).
5512
+ mi_call(await_form(FormId, Since, Change), StateIn, StateIn) :-
5513
+ !,
5514
+ get_session_id(SessionId),
5515
+ await_form_loop(SessionId, FormId, Since, StateIn, Change).
5516
+
5517
+ await_form_loop(SessionId, FormId, Since, StateIn, Change) :-
5518
+ engine_yield(wait_form(FormId, Since, 0)),
5519
+ get_next_signal(SessionId, _),
5520
+ session_form_result(SessionId, Result),
5521
+ retract(session_form_result(SessionId, _)),
5522
+ ( Result = ok(Dict0)
5523
+ -> form_change_atom_action(Dict0, Dict),
5524
+ ( get_dict(action, Dict, invalid)
5525
+ -> ( get_dict(errors, Dict, Errors)
5526
+ -> mi_call(form_errors(FormId, Errors), StateIn, _)
5527
+ ; true
5528
+ ),
5529
+ % Follow the registry's cursor, not the caller's original `Since`,
5530
+ % so the retry waits for a new attempt instead of re-reading the
5531
+ % submit that was just refused.
5532
+ await_form_loop(SessionId, FormId, -1, StateIn, Change)
5533
+ ; Change = Dict
5534
+ )
5535
+ ; throw(error(form_wait_failed(Result), await_form/3))
5536
+ ).
5537
+
5538
+ %% form_change_atom_action(+Change0, -Change)
5539
+ %% The host builds the change from JSON, so `action` arrives as a *string*. DML
5540
+ %% compares it to an atom — `Change.action == submit` (docs/DML_FORMS.md §3) — and
5541
+ %% `"submit" == submit` is false, which silently skipped every action branch.
5542
+ %% Convert here: the atom is the structural tag, and the model-facing tools
5543
+ %% re-serialize the dict to JSON, where an atom is a string again.
5544
+ form_change_atom_action(Change0, Change) :-
5545
+ ( get_dict(action, Change0, Action0), string(Action0)
5546
+ -> atom_string(Action, Action0),
5547
+ put_dict(action, Change0, Action, Change)
5548
+ ; Change = Change0
5549
+ ).
5550
+
5551
+ %% form_issue_dicts(+Issues, -Dicts)
5552
+ form_issue_dicts([], []).
5553
+ form_issue_dicts([Issue|Rest], [Dict|Dicts]) :-
5554
+ issue_dict(Issue, Dict),
5555
+ form_issue_dicts(Rest, Dicts).
5556
+
5557
+ issue_dict(error(Path, Message), issue{path: Path, level: error, message: Message}) :- !.
5558
+ issue_dict(warning(Path, Message), issue{path: Path, level: warning, message: Message}) :- !.
5559
+ issue_dict(issue{path: Path, level: Level, message: Message}, issue{path: Path, level: Level, message: Message}) :- !.
5560
+ %% A dict already shaped like an issue (e.g. parsed from JSON) passes through.
5561
+ issue_dict(Issue, Issue) :- is_dict(Issue), !.
5562
+
4899
5563
  %% mi_call(yield(Text), +StateIn, -StateOut)
4900
5564
  mi_call(yield(Text), StateIn, StateIn) :-
4901
5565
  get_params(StateIn, Params),
@@ -4923,6 +5587,22 @@ mi_call(format(Format, Args), StateIn, StateIn) :-
4923
5587
  ; format(Format, Args)
4924
5588
  ).
4925
5589
 
5590
+ %% mi_call(write(Value), +StateIn, -StateOut)
5591
+ %% `write/1` had no handler, so it fell through to SWI's current_output and its
5592
+ %% text vanished in the REPL — only `format/2` and `writeln/1` were captured. A
5593
+ %% debugging `write(step)` then looks exactly like a goal that produced nothing,
5594
+ %% which is the worst possible signal.
5595
+ mi_call(write(Value), StateIn, StateIn) :-
5596
+ get_session_id(SessionId),
5597
+ ( session_repl_mode(SessionId, true)
5598
+ -> with_output_to(string(Text), write(Value)),
5599
+ ( Text == ""
5600
+ -> true
5601
+ ; mi_call(output(Text), StateIn, StateIn)
5602
+ )
5603
+ ; write(Value)
5604
+ ).
5605
+
4926
5606
  %% mi_call(writeln(Value), +StateIn, -StateOut)
4927
5607
  mi_call(writeln(Value), StateIn, StateIn) :-
4928
5608
  get_session_id(SessionId),
@@ -5071,6 +5751,10 @@ repl_reserved_clause((tool(ToolHead, _) :- _)) :-
5071
5751
  !,
5072
5752
  functor(ToolHead, Name, _),
5073
5753
  repl_reserved_name(Name).
5754
+ repl_reserved_clause((tool(ToolHead, _, _) :- _)) :-
5755
+ !,
5756
+ functor(ToolHead, Name, _),
5757
+ repl_reserved_name(Name).
5074
5758
  repl_reserved_clause((tool(ToolHead) :- _)) :-
5075
5759
  !,
5076
5760
  functor(ToolHead, Name, _),
@@ -5652,6 +6336,41 @@ pattern_normalise(Value, Normalised) :-
5652
6336
  split_string(Lower, " _-", "", Parts),
5653
6337
  atomic_list_concat(Parts, '', Normalised).
5654
6338
 
6339
+ %% read_workspace_file(+SessionId, +File, -Content)
6340
+ %%
6341
+ %% `read_file_to_string/3` goes through SWI's `absolute_file_name/2`, which
6342
+ %% resolves differently inside an `execute` frame and can turn
6343
+ %% `/workspace/foo` into `/home/foo` (or a basename under another cwd). The
6344
+ %% mounted FS accepts the absolute path through open/4, so read it explicitly
6345
+ %% and join the lines. Relative paths are anchored at the session workspace.
6346
+ read_workspace_file(SessionId, File, Content) :-
6347
+ deepclause_mi:workspace_read_path(SessionId, File, Path),
6348
+ setup_call_cleanup(
6349
+ open(Path, read, Stream, [encoding(utf8)]),
6350
+ deepclause_mi:read_workspace_chars(Stream, Chars),
6351
+ close(Stream)
6352
+ ),
6353
+ string_chars(Content, Chars).
6354
+
6355
+ workspace_read_path(_SessionId, File, Path) :-
6356
+ ( string(File)
6357
+ -> Text = File
6358
+ ; atom(File)
6359
+ -> atom_string(File, Text)
6360
+ ; throw(error(type_error(file_name, File), context(read_workspace_file/3, _)))
6361
+ ),
6362
+ ( sub_string(Text, 0, 1, _, "/")
6363
+ -> Path = Text
6364
+ ; atomic_list_concat(['/workspace/', Text], '', Path)
6365
+ ).
6366
+
6367
+ read_workspace_chars(Stream, [Char|Chars]) :-
6368
+ get_char(Stream, Char),
6369
+ Char \== end_of_file,
6370
+ !,
6371
+ deepclause_mi:read_workspace_chars(Stream, Chars).
6372
+ read_workspace_chars(_, []).
6373
+
5655
6374
  %% repl_inject_workspace_predicates(+SessionId)
5656
6375
  %% Idempotent: assertz/1 the same clauses twice is harmless (a duplicate clause
5657
6376
  %% yields the same solution once? no - it yields twice), so clear first.
@@ -5662,9 +6381,9 @@ repl_inject_workspace_predicates(SessionId) :-
5662
6381
  catch(assertz(Clause), _, true)).
5663
6382
 
5664
6383
  workspace_predicate_clause(S, (S:read_file(F, C) :-
5665
- system:read_file_to_string(F, C, []))).
5666
- workspace_predicate_clause(S, (S:read_file_to_string(F, C, O) :-
5667
- system:read_file_to_string(F, C, O))).
6384
+ deepclause_mi:read_workspace_file(S, F, C))).
6385
+ workspace_predicate_clause(S, (S:read_file_to_string(F, C, _O) :-
6386
+ deepclause_mi:read_workspace_file(S, F, C))).
5668
6387
  workspace_predicate_clause(S, (S:write_file(F, C) :-
5669
6388
  system:open(F, write, St), system:write(St, C), system:close(St))).
5670
6389
  workspace_predicate_clause(S, (S:append_file(F, C) :-
@@ -5718,7 +6437,7 @@ mi_call(consult(File), StateIn, StateIn) :-
5718
6437
  !,
5719
6438
  get_session_id(SessionId),
5720
6439
  guard_not_frozen(SessionId),
5721
- system:read_file_to_string(File, Code, []),
6440
+ read_workspace_file(SessionId, File, Code),
5722
6441
  consult_string(Code, SessionId).
5723
6442
 
5724
6443
  mi_call(use_module(Module), StateIn, StateIn) :-
@@ -5756,7 +6475,7 @@ consult_string(Code, SessionId) :-
5756
6475
  consult_clauses(Stream, SessionId) :-
5757
6476
  read_term(Stream, Term, [module(SessionId), variable_names(Bindings)]),
5758
6477
  ( Term == end_of_file
5759
- -> true
6478
+ -> install_builtin_tools(SessionId)
5760
6479
  ; preprocess_loaded_term(Term, Bindings, PreparedTerms),
5761
6480
  forall(member(PreparedTerm, PreparedTerms), consult_process_term(PreparedTerm, SessionId)),
5762
6481
  consult_clauses(Stream, SessionId)
@@ -5814,15 +6533,17 @@ consult_process_term(Head, SessionId) :-
5814
6533
  assertz(SessionId:Head).
5815
6534
 
5816
6535
  %% mi_call(read_file_to_string(File, Content, Options), +StateIn, -StateOut)
5817
- mi_call(read_file_to_string(File, Content, Options), StateIn, StateIn) :-
6536
+ mi_call(read_file_to_string(File, Content, _Options), StateIn, StateIn) :-
5818
6537
  !,
5819
- system:read_file_to_string(File, Content, Options).
6538
+ get_session_id(SessionId),
6539
+ read_workspace_file(SessionId, File, Content).
5820
6540
 
5821
6541
  %% mi_call(read_file(File, Content), +StateIn, -StateOut)
5822
6542
  %% Simplified version without options
5823
6543
  mi_call(read_file(File, Content), StateIn, StateIn) :-
5824
6544
  !,
5825
- system:read_file_to_string(File, Content, []).
6545
+ get_session_id(SessionId),
6546
+ read_workspace_file(SessionId, File, Content).
5826
6547
 
5827
6548
  %% mi_call(write_file(File, Content), +StateIn, -StateOut)
5828
6549
  %% Write string content to a file
@@ -5844,11 +6565,21 @@ mi_call(append_file(File, Content), StateIn, StateIn) :-
5844
6565
  %% Open a file for reading/writing
5845
6566
  mi_call(open(File, Mode, Stream), StateIn, StateIn) :-
5846
6567
  !,
5847
- open(File, Mode, Stream).
6568
+ open(File, Mode, Stream),
6569
+ % A stream is not serializable, so a suspended session must not be holding
6570
+ % one. Track it here and `suspend/1` refuses while the ledger is non-empty.
6571
+ acquire_resource(file(File, Stream)).
6572
+
6573
+ %% mi_call(open(File, Mode, Stream, Options), +StateIn, -StateOut)
6574
+ mi_call(open(File, Mode, Stream, Options), StateIn, StateIn) :-
6575
+ !,
6576
+ open(File, Mode, Stream, Options),
6577
+ acquire_resource(file(File, Stream)).
5848
6578
 
5849
6579
  %% mi_call(close(Stream), +StateIn, -StateOut)
5850
6580
  mi_call(close(Stream), StateIn, StateIn) :-
5851
6581
  !,
6582
+ release_resources(file(_, Stream)),
5852
6583
  close(Stream).
5853
6584
 
5854
6585
  %% mi_call(write(Stream, Content), +StateIn, -StateOut)
@@ -6014,6 +6745,7 @@ is_mi_special_predicate(sleep(_)).
6014
6745
  is_mi_special_predicate(make_ref(_)).
6015
6746
  is_mi_special_predicate(now_ms(_)).
6016
6747
  is_mi_special_predicate(serve(_,_)).
6748
+ is_mi_special_predicate(serve(_,_,_)).
6017
6749
  is_mi_special_predicate(service_cast(_,_)).
6018
6750
  is_mi_special_predicate(service_call(_,_,_)).
6019
6751
  is_mi_special_predicate(service_call(_,_,_,_)).
@@ -6024,6 +6756,13 @@ is_mi_special_predicate(wait(_)).
6024
6756
  is_mi_special_predicate(wait(_,_)).
6025
6757
  is_mi_special_predicate(param(_,_)).
6026
6758
  is_mi_special_predicate(param(_,_,_)).
6759
+ %% Forms (docs/DML_FORMS.md)
6760
+ is_mi_special_predicate(form(_,_)).
6761
+ is_mi_special_predicate(form_update(_,_)).
6762
+ is_mi_special_predicate(form_errors(_,_)).
6763
+ is_mi_special_predicate(await_form(_,_)).
6764
+ is_mi_special_predicate(await_form(_,_,_)).
6765
+ is_mi_special_predicate(close_form(_)).
6027
6766
  %% Prompt predicates (like task but with fresh memory)
6028
6767
  is_mi_special_predicate(prompt(_)).
6029
6768
  is_mi_special_predicate(prompt(_,_)).
@@ -6054,7 +6793,14 @@ is_mi_special_predicate(require_judgment(_,_)).
6054
6793
  is_mi_special_predicate(get_memory(_)).
6055
6794
  is_mi_special_predicate(format(_,_)).
6056
6795
  is_mi_special_predicate(writeln(_)).
6796
+ is_mi_special_predicate(write(_)).
6797
+ is_mi_special_predicate(suspend(_)).
6057
6798
  is_mi_special_predicate(input(_,_)).
6799
+ %% Conversation (docs/DML_CONVERSATION.md)
6800
+ is_mi_special_predicate(input(_)).
6801
+ is_mi_special_predicate(chat(_)).
6802
+ is_mi_special_predicate(chat(_,_)).
6803
+ is_mi_special_predicate(next(_)).
6058
6804
  is_mi_special_predicate(call(_)).
6059
6805
  is_mi_special_predicate(call(_,_)).
6060
6806
  is_mi_special_predicate(call(_,_,_)).