deepclause-sdk 0.1.1 → 0.1.3

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 (122) hide show
  1. package/dist/agent.d.ts +12 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +116 -9
  4. package/dist/agent.js.map +1 -1
  5. package/dist/cli/config.d.ts +24 -24
  6. package/dist/cli/repl.d.ts +21 -4
  7. package/dist/cli/repl.d.ts.map +1 -1
  8. package/dist/cli/repl.js +293 -44
  9. package/dist/cli/repl.js.map +1 -1
  10. package/dist/cli/run.d.ts.map +1 -1
  11. package/dist/cli/run.js +10 -0
  12. package/dist/cli/run.js.map +1 -1
  13. package/dist/prolog/loader.d.ts +32 -0
  14. package/dist/prolog/loader.d.ts.map +1 -1
  15. package/dist/prolog/loader.js +87 -0
  16. package/dist/prolog/loader.js.map +1 -1
  17. package/dist/prolog-src/deepclause_mi.pl +1028 -118
  18. package/dist/repl/agent.d.ts +15 -1
  19. package/dist/repl/agent.d.ts.map +1 -1
  20. package/dist/repl/agent.js +44 -5
  21. package/dist/repl/agent.js.map +1 -1
  22. package/dist/repl/backend.d.ts.map +1 -1
  23. package/dist/repl/backend.js +18 -5
  24. package/dist/repl/backend.js.map +1 -1
  25. package/dist/repl/harness-tools.d.ts +24 -2
  26. package/dist/repl/harness-tools.d.ts.map +1 -1
  27. package/dist/repl/harness-tools.js +14 -5
  28. package/dist/repl/harness-tools.js.map +1 -1
  29. package/dist/repl/index.d.ts +8 -0
  30. package/dist/repl/index.d.ts.map +1 -1
  31. package/dist/repl/index.js +1 -0
  32. package/dist/repl/index.js.map +1 -1
  33. package/dist/repl/render.d.ts +60 -3
  34. package/dist/repl/render.d.ts.map +1 -1
  35. package/dist/repl/render.js +114 -28
  36. package/dist/repl/render.js.map +1 -1
  37. package/dist/repl/session.d.ts +61 -4
  38. package/dist/repl/session.d.ts.map +1 -1
  39. package/dist/repl/session.js +174 -9
  40. package/dist/repl/session.js.map +1 -1
  41. package/dist/repl/status.d.ts +3 -1
  42. package/dist/repl/status.d.ts.map +1 -1
  43. package/dist/repl/status.js +4 -0
  44. package/dist/repl/status.js.map +1 -1
  45. package/dist/repl/tool-arguments.d.ts +36 -0
  46. package/dist/repl/tool-arguments.d.ts.map +1 -0
  47. package/dist/repl/tool-arguments.js +123 -0
  48. package/dist/repl/tool-arguments.js.map +1 -0
  49. package/dist/repl/toolset.d.ts +28 -1
  50. package/dist/repl/toolset.d.ts.map +1 -1
  51. package/dist/repl/toolset.js +52 -4
  52. package/dist/repl/toolset.js.map +1 -1
  53. package/dist/repl/tui/autocomplete.d.ts +19 -0
  54. package/dist/repl/tui/autocomplete.d.ts.map +1 -0
  55. package/dist/repl/tui/autocomplete.js +41 -0
  56. package/dist/repl/tui/autocomplete.js.map +1 -0
  57. package/dist/repl/tui/document.d.ts +51 -0
  58. package/dist/repl/tui/document.d.ts.map +1 -0
  59. package/dist/repl/tui/document.js +160 -0
  60. package/dist/repl/tui/document.js.map +1 -0
  61. package/dist/repl/tui/picker.d.ts +31 -0
  62. package/dist/repl/tui/picker.d.ts.map +1 -0
  63. package/dist/repl/tui/picker.js +43 -0
  64. package/dist/repl/tui/picker.js.map +1 -0
  65. package/dist/repl/tui/renderer.d.ts +98 -0
  66. package/dist/repl/tui/renderer.d.ts.map +1 -0
  67. package/dist/repl/tui/renderer.js +284 -0
  68. package/dist/repl/tui/renderer.js.map +1 -0
  69. package/dist/repl/tui/status-bar.d.ts +4 -2
  70. package/dist/repl/tui/status-bar.d.ts.map +1 -1
  71. package/dist/repl/tui/status-bar.js +80 -24
  72. package/dist/repl/tui/status-bar.js.map +1 -1
  73. package/dist/repl/tui/terminal.d.ts +28 -0
  74. package/dist/repl/tui/terminal.d.ts.map +1 -0
  75. package/dist/repl/tui/terminal.js +94 -0
  76. package/dist/repl/tui/terminal.js.map +1 -0
  77. package/dist/repl/tui/theme.d.ts +60 -0
  78. package/dist/repl/tui/theme.d.ts.map +1 -1
  79. package/dist/repl/tui/theme.js +142 -0
  80. package/dist/repl/tui/theme.js.map +1 -1
  81. package/dist/repl/tui/tool-pane.d.ts +68 -0
  82. package/dist/repl/tui/tool-pane.d.ts.map +1 -0
  83. package/dist/repl/tui/tool-pane.js +257 -0
  84. package/dist/repl/tui/tool-pane.js.map +1 -0
  85. package/dist/repl/tui/working-line.d.ts +23 -0
  86. package/dist/repl/tui/working-line.d.ts.map +1 -0
  87. package/dist/repl/tui/working-line.js +58 -0
  88. package/dist/repl/tui/working-line.js.map +1 -0
  89. package/dist/runner.d.ts +31 -0
  90. package/dist/runner.d.ts.map +1 -1
  91. package/dist/runner.js +192 -17
  92. package/dist/runner.js.map +1 -1
  93. package/dist/system/assets/docs/AGENT_PROMPT.md +925 -43
  94. package/dist/system/assets/docs/DML_AUTHORING.md +96 -17
  95. package/dist/system/assets/docs/DML_REFERENCE.md +620 -39
  96. package/dist/system/assets/docs/PROLOG_SNIPPETS.md +45 -1
  97. package/dist/system/assets/docs/TASK_PROMPT.md +2 -0
  98. package/dist/system/environment.d.ts +36 -0
  99. package/dist/system/environment.d.ts.map +1 -0
  100. package/dist/system/environment.js +81 -0
  101. package/dist/system/environment.js.map +1 -0
  102. package/dist/system/runtime/shell-manager.d.ts +40 -2
  103. package/dist/system/runtime/shell-manager.d.ts.map +1 -1
  104. package/dist/system/runtime/shell-manager.js +82 -16
  105. package/dist/system/runtime/shell-manager.js.map +1 -1
  106. package/dist/system/runtime/shell-tool-events.js +25 -1
  107. package/dist/system/runtime/shell-tool-events.js.map +1 -1
  108. package/dist/types.d.ts +27 -1
  109. package/dist/types.d.ts.map +1 -1
  110. package/package.json +4 -2
  111. package/src/prolog-src/deepclause_mi.pl +1028 -118
  112. package/dist/compaction.d.ts +0 -98
  113. package/dist/compaction.d.ts.map +0 -1
  114. package/dist/compaction.js +0 -471
  115. package/dist/compaction.js.map +0 -1
  116. package/dist/repl/tools.d.ts +0 -53
  117. package/dist/repl/tools.d.ts.map +0 -1
  118. package/dist/repl/tools.js +0 -307
  119. package/dist/repl/tools.js.map +0 -1
  120. package/dist/system/assets/compactors/default-loop-compactor.dml +0 -43
  121. package/dist/system/assets/compactors/default-session-compactor.dml +0 -30
  122. package/dist/system/assets/docs/META_PROMPT.md +0 -68
@@ -61,6 +61,13 @@
61
61
  repl_edit_touched/2,
62
62
  repl_edit_touched_text/2,
63
63
  repl_edit_touched_clear/1,
64
+ repl_validate_tools/2,
65
+ repl_validate_docs/2,
66
+ schema_fill_fields/3,
67
+ schema_fields_of/3,
68
+ repl_unhandled_directives/2,
69
+ repl_inject_workspace_predicates/1,
70
+ workspace_predicate_name/1,
64
71
  repl_set_dml_reference/2,
65
72
  repl_set_authoring_examples/2,
66
73
  repl_manifest/2,
@@ -116,6 +123,7 @@
116
123
  :- dynamic session_repl_memory/2. % session_repl_memory(SessionId, HarnessMemory) - object/entry memory
117
124
  :- dynamic session_meta_memory/2. % session_meta_memory(SessionId, MetaMemory) - code-writing task memory
118
125
  :- dynamic session_repl_mode/2. % session_repl_mode(SessionId, true/false) - gates format/writeln output shims
126
+ :- dynamic session_unhandled_directive/2. % session_unhandled_directive(SessionId, Directive)
119
127
  :- dynamic session_frozen/2. % session_frozen(SessionId, true/false) - read-only lock
120
128
  :- dynamic session_locked/2. % session_locked(SessionId, true/false) - irreversibly frozen (--locked)
121
129
  :- dynamic session_directive/2. % session_directive(SessionId, Directive) - recorded :- directives for export
@@ -153,7 +161,8 @@
153
161
  parse_dml(FilePath, SessionId, _MemoryId, Params, Error) :-
154
162
  catch(
155
163
  (
156
- read_file_to_string(FilePath, Code, []),
164
+ % FilePath is an absolute host temp path, not workspace-relative.
165
+ system:read_file_to_string(FilePath, Code, []),
157
166
  assert_params(SessionId, Params),
158
167
  parse_dml_string(Code, SessionId),
159
168
  Error = none
@@ -310,6 +319,11 @@ extract_var_info(Bindings, Term, BareVar, Info) :-
310
319
  BareVar = Term,
311
320
  var_to_name(Bindings, Term, Name),
312
321
  Info = typed_var{name: Name, type: string}
322
+ ; compound(Term), Term =.. [schema, SchemaName, Inner], var(Inner)
323
+ -> % A declared shape: schema(name, Var)
324
+ BareVar = Inner,
325
+ var_to_name(Bindings, Inner, Name),
326
+ Info = typed_var{name: Name, type: object, schema: SchemaName}
313
327
  ; compound(Term), Term =.. [Wrapper, Inner]
314
328
  -> % Wrapped variable
315
329
  ( wrapper_type(Wrapper, Type)
@@ -317,6 +331,11 @@ extract_var_info(Bindings, Term, BareVar, Info) :-
317
331
  -> BareVar = Inner,
318
332
  var_to_name(Bindings, Inner, Name),
319
333
  Info = typed_var{name: Name, type: Type}
334
+ ; compound(Inner), Inner =.. [schema, SchemaName, InnerVar], var(InnerVar), Wrapper == list
335
+ -> % List of declared shapes: list(schema(name, X))
336
+ BareVar = InnerVar,
337
+ var_to_name(Bindings, InnerVar, Name),
338
+ Info = typed_var{name: Name, type: array, itemType: object, schema: SchemaName}
320
339
  ; compound(Inner), Inner =.. [InnerWrapper, InnerVar], var(InnerVar), Wrapper == list
321
340
  -> % List of typed items: list(string(X))
322
341
  BareVar = InnerVar,
@@ -431,6 +450,9 @@ clean_atomic(with_tools(Tools, Goal), with_tools(Tools, CleanGoal)) :-
431
450
  clean_atomic(without_tools(Tools, Goal), without_tools(Tools, CleanGoal)) :-
432
451
  !,
433
452
  clean_clause_body(Goal, CleanGoal).
453
+ clean_atomic(with_fresh_memory(Goal), with_fresh_memory(CleanGoal)) :-
454
+ !,
455
+ clean_clause_body(Goal, CleanGoal).
434
456
  clean_atomic(with_judgment(Backend, Goal), with_judgment(Backend, CleanGoal)) :-
435
457
  !,
436
458
  clean_clause_body(Goal, CleanGoal).
@@ -742,8 +764,7 @@ process_directive(consult(File), SessionId) :-
742
764
  !,
743
765
  % Set session ID for get_session_id to find, then call our custom consult
744
766
  nb_setval(current_session_id, SessionId),
745
- resolve_workspace_path(File, FullPath),
746
- readutil:read_file_to_string(FullPath, Code, []),
767
+ system:read_file_to_string(File, Code, []),
747
768
  consult_string(Code, SessionId).
748
769
  %% Handle use_module directive
749
770
  process_directive(use_module(Module), SessionId) :-
@@ -766,7 +787,80 @@ process_directive([File|Files], SessionId) :-
766
787
  nb_setval(current_session_id, SessionId),
767
788
  process_directive(consult(File), SessionId),
768
789
  (Files == [] -> true ; process_directive(Files, SessionId)).
769
- process_directive(_, _).
790
+ %% `:- repl_delete(Name/Arity).` — the directive form of the fact handled by
791
+ %% process_clause/2 below. It used to fall through to the catch-all and be
792
+ %% discarded, so a predicate the author asked to remove stayed in the module.
793
+ process_directive(repl_delete(Name/Arity), SessionId) :-
794
+ !,
795
+ atom(Name),
796
+ integer(Arity),
797
+ Arity >= 0,
798
+ catch(abolish(SessionId:Name/Arity), _, true).
799
+
800
+ %% process_directive(require_sandbox, +SessionId)
801
+ %% The harness declares that it must run in an execution sandbox, and the load
802
+ %% fails if it cannot. Checked against the **effective** backend rather than
803
+ %% mere availability: bwrap being installed while the session resolves to
804
+ %% clean-room means the code would run unsandboxed, which is what the directive
805
+ %% exists to prevent. Only the user sets this (see repl_commit_directive/2).
806
+ process_directive(require_sandbox, SessionId) :-
807
+ ( session_sandbox_backend(SessionId, Kind)
808
+ -> true
809
+ ; Kind = 'clean-room'
810
+ ),
811
+ ( sandbox_backend_acceptable(Kind)
812
+ -> true
813
+ ; format(atom(Message),
814
+ 'harness requires an execution sandbox, but the effective backend is ~w; bwrap, AgentVM or sandbox-exec is required. Install bubblewrap, run with --sandbox (AgentVM), or remove the :- require_sandbox. directive.',
815
+ [Kind]),
816
+ throw(error(permission_error(load, harness, require_sandbox),
817
+ context(require_sandbox/0, Message)))
818
+ ).
819
+
820
+ %% Anything else is recorded rather than discarded. An unrecognised directive
821
+ %% (`:- dynamic(f/1).`, a `:- op(...)` without use_module, a typo) does nothing,
822
+ %% and silently doing nothing is how an edit that never took effect goes
823
+ %% unnoticed. The host reports these after a load.
824
+ process_directive(Directive, SessionId) :-
825
+ ( repl_benign_directive(Directive)
826
+ -> true
827
+ ; assertz(session_unhandled_directive(SessionId, Directive))
828
+ ).
829
+
830
+ %% Directives DML deliberately accepts and ignores (SWI state it manages itself).
831
+ repl_benign_directive(dynamic(_)).
832
+ repl_benign_directive(dynamic(_, _)).
833
+ repl_benign_directive(discontiguous(_)).
834
+ repl_benign_directive(discontiguous(_, _)).
835
+ repl_benign_directive(multifile(_)).
836
+ repl_benign_directive(multifile(_, _)).
837
+
838
+ %% sandbox_kind_atom(+Raw, -Atom)
839
+ %% A JS string or an atom in, an atom out.
840
+ %%
841
+ %% Uses term_string/3, not atom_string/2: in this WASM build `atom_string/2`
842
+ %% returns the string unchanged, so an "atom" produced by it still fails `atom/1`
843
+ %% and compares unequal to every atom literal. That silently made every backend
844
+ %% look unacceptable. `term_string(Atom, "bwrap", [])` does produce the atom.
845
+ sandbox_kind_atom(Raw, Atom) :-
846
+ ( atom(Raw)
847
+ -> Atom = Raw
848
+ ; catch(term_string(Atom, Raw, []), _, Atom = Raw)
849
+ ).
850
+
851
+ %% sandbox_backend_acceptable(+Kind)
852
+ %% The backends that provide filesystem isolation for `exec(bash(...))`.
853
+ %% `clean-room` is the absence of a wrapper and does not qualify. The host
854
+ %% normalizes `sandbox-exec` to `sandbox_exec` (see DMLSession.open) because a
855
+ %% hyphen makes the token a subtraction expression to a Prolog parser.
856
+ sandbox_backend_acceptable(agentvm).
857
+ sandbox_backend_acceptable(bwrap).
858
+ sandbox_backend_acceptable(sandbox_exec).
859
+
860
+
861
+ %% repl_unhandled_directives(+SessionId, -Directives)
862
+ repl_unhandled_directives(SessionId, Directives) :-
863
+ findall(D, session_unhandled_directive(SessionId, D), Directives).
770
864
 
771
865
  %% ============================================================
772
866
  %% Engine Management
@@ -919,7 +1013,17 @@ create_session(SessionId, Params, HarnessMemory, MetaMemory) :-
919
1013
  retractall(session_frozen(SessionId, _)),
920
1014
  assertz(session_frozen(SessionId, false)),
921
1015
  retractall(session_locked(SessionId, _)),
922
- assertz(session_locked(SessionId, false)).
1016
+ assertz(session_locked(SessionId, false)),
1017
+ % Stored as an atom. The host sends a JS string, and an atom and a string
1018
+ % that print identically are not `==`, so normalize once here rather than at
1019
+ % every comparison.
1020
+ ( get_dict(sandbox, Params, SandboxRaw)
1021
+ -> sandbox_kind_atom(SandboxRaw, SandboxKind)
1022
+ ; SandboxKind = 'clean-room'
1023
+ ),
1024
+ retractall(session_sandbox_backend(SessionId, _)),
1025
+ assertz(session_sandbox_backend(SessionId, SandboxKind)),
1026
+ repl_inject_workspace_predicates(SessionId).
923
1027
 
924
1028
  %% repl_abolish_user_predicates(+SessionId)
925
1029
  %% Removes only the harness's own predicates; imported builtins are untouched.
@@ -1238,6 +1342,7 @@ internal_session_predicate(param).
1238
1342
  internal_session_predicate(param_decl).
1239
1343
  internal_session_predicate(tool_source).
1240
1344
  internal_session_predicate('__session__').
1345
+ internal_session_predicate(Name) :- workspace_predicate_name(Name).
1241
1346
 
1242
1347
  %% history_predicate(+Name)
1243
1348
  %% Runtime-owned harness-history facts. Excluded from the user predicate set (so
@@ -1267,6 +1372,7 @@ repl_reserved_name(repl_reserved_name).
1267
1372
  repl_reserved_name(repl_tool_catalog).
1268
1373
  repl_reserved_name(repl_listing).
1269
1374
  repl_reserved_name(repl_assert).
1375
+ repl_reserved_name(require_sandbox).
1270
1376
  repl_reserved_name(repl_retract).
1271
1377
  repl_reserved_name(repl_delete).
1272
1378
  repl_reserved_name(repl_freeze).
@@ -1502,12 +1608,11 @@ repl_script_files(SessionId, Paths) :-
1502
1608
  repl_materialize_embedded(SessionId) :-
1503
1609
  ( current_predicate(SessionId:embedded_file/2)
1504
1610
  -> forall(SessionId:embedded_file(Path, Content),
1505
- ( resolve_workspace_path(Path, Full),
1506
- catch(
1507
- ( file_directory_name(Full, Dir),
1611
+ ( catch(
1612
+ ( file_directory_name(Path, Dir),
1508
1613
  make_directory_path(Dir),
1509
1614
  setup_call_cleanup(
1510
- open(Full, write, Stream),
1615
+ open(Path, write, Stream),
1511
1616
  format(Stream, "~s", [Content]),
1512
1617
  close(Stream))
1513
1618
  ),
@@ -1604,6 +1709,8 @@ repl_assert(SessionId, ClauseText, Result) :-
1604
1709
  catch(
1605
1710
  ( guard_not_frozen(SessionId),
1606
1711
  repl_prepare_single(SessionId, ClauseText, PreparedTerms),
1712
+ forall(member(PreparedTerm, PreparedTerms),
1713
+ repl_require_new_predicate(SessionId, PreparedTerm)),
1607
1714
  forall(member(PreparedTerm, PreparedTerms),
1608
1715
  repl_assert_clause(PreparedTerm, SessionId)),
1609
1716
  Result = ok
@@ -1663,11 +1770,25 @@ repl_read_prepared_terms(Stream, SessionId, Terms) :-
1663
1770
  % their operators are visible to the terms that follow; keep them out of
1664
1771
  % the returned list so repl_commit_terms does not process them twice.
1665
1772
  partition_directives(Prepared, Rules, Directives),
1666
- forall(member(Directive, Directives), process_clause(Directive, SessionId)),
1773
+ forall(member(Directive, Directives), repl_commit_directive(Directive, SessionId)),
1667
1774
  append(Rules, Rest, Terms),
1668
1775
  repl_read_prepared_terms(Stream, SessionId, Rest)
1669
1776
  ).
1670
1777
 
1778
+ %% repl_commit_directive(+Directive, +SessionId)
1779
+ %% Directives written by the *agent* (assert_many/define) run here. `require_sandbox`
1780
+ %% is the user's to set — through `/set-require-sandbox` — so an agent cannot
1781
+ %% declare a requirement it was not asked for, nor lift one by re-asserting the
1782
+ %% harness. The file-load path (parse_clauses/2) is deliberately not guarded:
1783
+ %% that is what the user's own harness file goes through.
1784
+ repl_commit_directive((:- require_sandbox), _SessionId) :-
1785
+ !,
1786
+ throw(error(permission_error(modify, reserved_name, require_sandbox),
1787
+ context(repl_commit_directive/2,
1788
+ 'only the user can set the sandbox requirement'))).
1789
+ repl_commit_directive(Directive, SessionId) :-
1790
+ process_clause(Directive, SessionId).
1791
+
1671
1792
  partition_directives([], [], []).
1672
1793
  partition_directives([Term|Terms], Rules, Directives) :-
1673
1794
  ( Term = (:- _)
@@ -1696,6 +1817,9 @@ repl_assert_many(SessionId, ClausesText, Result) :-
1696
1817
  catch(
1697
1818
  ( guard_not_frozen(SessionId),
1698
1819
  repl_parse_clauses(SessionId, ClausesText, Terms),
1820
+ % assert appends; against an existing predicate that leaves both
1821
+ % versions live with their clauses interleaved.
1822
+ forall(member(Term, Terms), repl_require_new_predicate(SessionId, Term)),
1699
1823
  repl_commit_terms(SessionId, Terms),
1700
1824
  repl_note_touched(SessionId, Terms),
1701
1825
  Result = ok
@@ -1719,11 +1843,105 @@ repl_term_indicator(Term, Name/Arity) :-
1719
1843
  %% Record the predicate indicators a term list defines, for history recording.
1720
1844
  repl_note_touched(SessionId, Terms) :-
1721
1845
  forall(( member(Term, Terms), repl_term_indicator(Term, Name/Arity) ),
1722
- ( session_edit_touched(SessionId, Name/Arity)
1723
- -> true
1724
- ; assertz(session_edit_touched(SessionId, Name/Arity))
1846
+ repl_note_indicator(SessionId, Name/Arity)).
1847
+
1848
+ repl_note_indicator(SessionId, Name/Arity) :-
1849
+ ( session_edit_touched(SessionId, Name/Arity)
1850
+ -> true
1851
+ ; assertz(session_edit_touched(SessionId, Name/Arity))
1852
+ ).
1853
+
1854
+ %% repl_require_new_predicate(+SessionId, +Term)
1855
+ %% `assert`/`assert_many` append. Against a predicate that already has clauses
1856
+ %% that mixes the old and the new version — both stay live, clause order
1857
+ %% interleaves them, and nothing says so. Refuse instead, and name the remedy.
1858
+ repl_require_new_predicate(SessionId, Term) :-
1859
+ repl_term_indicator(Term, Name/Arity),
1860
+ findall(x,
1861
+ ( functor(Head, Name, Arity),
1862
+ clause(SessionId:Head, _)
1863
+ ),
1864
+ Existing),
1865
+ Existing \== [],
1866
+ !,
1867
+ length(Existing, Count),
1868
+ format(atom(Message),
1869
+ '~w/~w already has ~w clause(s). assert appends, so the old and the new clauses would both be present and clause order would interleave them. Use define("~w/~w", "...") to replace the predicate, or delete("~w/~w") first.',
1870
+ [Name, Arity, Count, Name, Arity, Name, Arity]),
1871
+ throw(error(permission_error(modify, existing_predicate, Name/Arity),
1872
+ context(repl_require_new_predicate/2, Message))).
1873
+ repl_require_new_predicate(_SessionId, _Term).
1874
+
1875
+ %% repl_forget_tool(+SessionId, +Name)
1876
+ %% Drop the tool annotation that belonged to a predicate being removed.
1877
+ repl_forget_tool(SessionId, Name) :-
1878
+ ( session_user_tools(SessionId, Name)
1879
+ -> retractall(session_user_tools(SessionId, Name)),
1880
+ retractall(session_user_tool_schema(SessionId, Name, _)),
1881
+ catch(retractall(SessionId:tool_source(Name, _)), _, true)
1882
+ ; true
1883
+ ).
1884
+
1885
+ %% repl_capture_predicates(+SessionId, +Indicators, -Captured)
1886
+ %% Copy the clauses and tool bookkeeping a replacement is about to destroy, so
1887
+ %% a failure part-way through can put them back.
1888
+ repl_capture_predicates(SessionId, Indicators, Captured) :-
1889
+ findall(indicator(Name, Arity, Clauses, tool(IsTool, Schema, Source)),
1890
+ ( member(Name/Arity, Indicators),
1891
+ findall(Stored,
1892
+ ( functor(Head, Name, Arity),
1893
+ clause(SessionId:Head, Body),
1894
+ ( Body == true
1895
+ -> Stored = Head
1896
+ ; Stored = (Head :- Body)
1897
+ )
1898
+ ),
1899
+ Clauses),
1900
+ ( session_user_tools(SessionId, Name)
1901
+ -> IsTool = true
1902
+ ; IsTool = false
1903
+ ),
1904
+ ( session_user_tool_schema(SessionId, Name, Found)
1905
+ -> Schema = Found
1906
+ ; Schema = none
1907
+ ),
1908
+ ( current_predicate(SessionId:tool_source/2),
1909
+ SessionId:tool_source(Name, FoundSource)
1910
+ -> Source = FoundSource
1911
+ ; Source = none
1912
+ )
1913
+ ),
1914
+ Captured).
1915
+
1916
+ %% repl_restore_captured(+SessionId, +Captured)
1917
+ repl_restore_captured(SessionId, Captured) :-
1918
+ forall(member(indicator(Name, Arity, Clauses, tool(IsTool, Schema, Source)), Captured),
1919
+ ( catch(abolish(SessionId:Name/Arity), _, true),
1920
+ repl_forget_tool(SessionId, Name),
1921
+ forall(member(Clause, Clauses), assertz(SessionId:Clause)),
1922
+ ( IsTool == true
1923
+ -> assertz(session_user_tools(SessionId, Name))
1924
+ ; true
1925
+ ),
1926
+ ( Schema == none
1927
+ -> true
1928
+ ; assertz(session_user_tool_schema(SessionId, Name, Schema))
1929
+ ),
1930
+ ( Source == none
1931
+ -> true
1932
+ ; assertz(SessionId:tool_source(Name, Source))
1933
+ )
1725
1934
  )).
1726
1935
 
1936
+ %% repl_replace_predicates(+SessionId, +Indicators, +Terms)
1937
+ %% The destructive half of define: abolish the targets, then assert the block.
1938
+ repl_replace_predicates(SessionId, Indicators, Terms) :-
1939
+ forall(member(Name/Arity, Indicators),
1940
+ ( catch(abolish(SessionId:Name/Arity), _, true),
1941
+ repl_forget_tool(SessionId, Name)
1942
+ )),
1943
+ repl_commit_terms(SessionId, Terms).
1944
+
1727
1945
  %% repl_edit_touched(+SessionId, -Indicators)
1728
1946
  %% Predicates touched by edit tools since the last clear (sorted, unique).
1729
1947
  repl_edit_touched(SessionId, Indicators) :-
@@ -1754,7 +1972,13 @@ repl_system_functor(Name/_) :-
1754
1972
  member(Name, ['.', ',', ';', ':-', '->', ':', '|', '!', '\\+', '=', 'is']).
1755
1973
 
1756
1974
  %% repl_retract(+SessionId, +ClausePattern, -Result)
1757
- %% Retracts matching user clauses. Reserved names are protected.
1975
+ %% Removes the clauses that match the pattern, and says how many.
1976
+ %%
1977
+ %% This used to build a fresh head with `functor/3` and call `retractall/1`, so
1978
+ %% the pattern's *arguments were discarded*: `retract("g(1)")` removed every
1979
+ %% g/1 clause and reported `ok`. It now retracts what was asked for, and fails
1980
+ %% loudly when nothing matched — a silent no-op is how an agent concludes a
1981
+ %% clause is gone when it never was.
1758
1982
  repl_retract(SessionId, ClauseText, Result) :-
1759
1983
  catch(
1760
1984
  ( guard_not_frozen(SessionId),
@@ -1766,19 +1990,29 @@ repl_retract(SessionId, ClauseText, Result) :-
1766
1990
  context(repl_retract/3, 'cannot retract a reserved predicate')))
1767
1991
  ; true
1768
1992
  ),
1769
- functor(Head, Name, Arity),
1770
- retractall(SessionId:Head),
1771
- ( session_user_tools(SessionId, Name)
1772
- -> retractall(session_user_tools(SessionId, Name)),
1773
- retractall(session_user_tool_schema(SessionId, Name, _)),
1774
- retractall(SessionId:tool_source(Name, _))
1993
+ findall(x, retract(SessionId:Pattern), Removed),
1994
+ length(Removed, RemovedCount),
1995
+ ( RemovedCount =:= 0
1996
+ -> format(atom(Message),
1997
+ 'no clause of ~w/~w matched ~w, so nothing was removed',
1998
+ [Name, Arity, Clause]),
1999
+ throw(error(existence_error(clause, Clause),
2000
+ context(repl_retract/3, Message)))
1775
2001
  ; true
1776
2002
  ),
1777
- ( session_edit_touched(SessionId, Name/Arity)
2003
+ % Only drop the tool annotation when the predicate is really gone;
2004
+ % other clauses of it may still be live.
2005
+ ( functor(Head, Name, Arity),
2006
+ clause(SessionId:Head, _)
1778
2007
  -> true
1779
- ; assertz(session_edit_touched(SessionId, Name/Arity))
2008
+ ; repl_forget_tool(SessionId, Name)
1780
2009
  ),
1781
- Result = ok
2010
+ repl_note_indicator(SessionId, Name/Arity),
2011
+ ( RemovedCount =:= 1
2012
+ -> Suffix = ''
2013
+ ; Suffix = 's'
2014
+ ),
2015
+ format(atom(Result), 'ok(removed ~w clause~w)', [RemovedCount, Suffix])
1782
2016
  ),
1783
2017
  RetractError,
1784
2018
  format(atom(Result), 'error(~w)', [RetractError])
@@ -1814,34 +2048,45 @@ repl_delete_predicate(SessionId, Name, Arity, Result) :-
1814
2048
  ).
1815
2049
 
1816
2050
  %% repl_define(+SessionId, +HeadPatternText, +ClausesText, -Result)
1817
- %% Replace every predicate the clause block defines. It parses the block,
1818
- %% computes its indicators (plus the head pattern's), abolishes each, then
1819
- %% asserts the block. This makes `define` safe no matter how many predicates a
1820
- %% block spans, so a multi-predicate block cannot append duplicate clauses.
2051
+ %% Replace every predicate the clause block defines, all or nothing.
2052
+ %%
2053
+ %% The order matters and used to be wrong. Abolishing first and asserting after
2054
+ %% means any failure in between leaves the target *destroyed*: a block with one
2055
+ %% reserved name in it removed the predicate it was replacing and reported an
2056
+ %% error, and a following `end_turn` then committed the deletion as a version.
2057
+ %% So everything that can fail is checked before anything is touched, and the
2058
+ %% clauses being replaced are copied first so a failure can still put them back.
1821
2059
  repl_define(SessionId, HeadPatternText, ClausesText, Result) :-
1822
2060
  catch(
1823
2061
  ( guard_not_frozen(SessionId),
1824
- repl_read_clause(SessionId, HeadPatternText, Pattern),
1825
- repl_head_of(Pattern, HeadClause),
1826
- repl_callable_indicator(HeadClause, HeadName, HeadArity),
2062
+ repl_define_target(SessionId, HeadPatternText, HeadName, HeadArity),
1827
2063
  repl_parse_clauses(SessionId, ClausesText, Terms),
1828
2064
  findall(Name/Arity,
1829
2065
  ( member(Term, Terms), repl_term_indicator(Term, Name/Arity) ),
1830
2066
  BlockIndicators0),
1831
- sort([HeadName/HeadArity|BlockIndicators0], Indicators),
1832
- forall(member(Name/Arity, Indicators),
1833
- ( repl_protected_name(Name)
1834
- -> throw(error(permission_error(modify, reserved_name, Name/Arity),
1835
- context(repl_define/4, 'cannot define a reserved predicate')))
1836
- ; catch(abolish(SessionId:Name/Arity), _, true),
1837
- ( session_user_tools(SessionId, Name)
1838
- -> retractall(session_user_tools(SessionId, Name)),
1839
- retractall(session_user_tool_schema(SessionId, Name, _)),
1840
- retractall(SessionId:tool_source(Name, _))
1841
- ; true
1842
- )
1843
- )),
1844
- repl_commit_terms(SessionId, Terms),
2067
+ sort(BlockIndicators0, BlockIndicators),
2068
+ % The head names the predicate being replaced. If it is not one of
2069
+ % them the target is almost certainly a typo, and silently replacing
2070
+ % a *different* predicate is worse than refusing.
2071
+ ( BlockIndicators \== [],
2072
+ \+ member(HeadName/HeadArity, BlockIndicators)
2073
+ -> format(atom(TargetMessage),
2074
+ 'define was told to replace ~w/~w, but the clause block defines ~w. Pass the predicate you are replacing as the head.',
2075
+ [HeadName, HeadArity, BlockIndicators]),
2076
+ throw(error(domain_error(define_target, HeadName/HeadArity),
2077
+ context(repl_define/4, TargetMessage)))
2078
+ ; true
2079
+ ),
2080
+ sort([HeadName/HeadArity|BlockIndicators], Indicators),
2081
+ % Validate everything that can fail, before anything is removed.
2082
+ forall(member(Term, Terms), repl_require_committable(SessionId, Term)),
2083
+ forall(member(Name/Arity, Indicators), repl_require_not_reserved(SessionId, Name/Arity)),
2084
+ repl_capture_predicates(SessionId, Indicators, Captured),
2085
+ ( catch(repl_replace_predicates(SessionId, Indicators, Terms), ReplaceError,
2086
+ ( repl_restore_captured(SessionId, Captured),
2087
+ throw(ReplaceError)
2088
+ ))
2089
+ ),
1845
2090
  repl_note_touched(SessionId, Terms),
1846
2091
  Result = ok
1847
2092
  ),
@@ -1849,6 +2094,42 @@ repl_define(SessionId, HeadPatternText, ClausesText, Result) :-
1849
2094
  format(atom(Result), 'error(~w)', [DefineError])
1850
2095
  ).
1851
2096
 
2097
+ %% repl_define_target(+SessionId, +HeadText, -Name, -Arity)
2098
+ %% The first argument of `define` names the predicate being replaced, written as
2099
+ %% an indicator (`f/1`) or as a clause head (`f(_)`).
2100
+ %%
2101
+ %% It has to be decomposed explicitly: `f/1` *reads* as the operator term
2102
+ %% `/(f, 1)`, so treating it as a clause head yields the functor `//2`. That is
2103
+ %% what used to happen — the target was silently ignored and the clause block
2104
+ %% alone decided what got replaced, so `define` accepted any name at all.
2105
+ repl_define_target(SessionId, HeadText, Name, Arity) :-
2106
+ repl_read_clause(SessionId, HeadText, Pattern),
2107
+ ( Pattern = IndicatorName/IndicatorArity,
2108
+ atom(IndicatorName),
2109
+ integer(IndicatorArity),
2110
+ IndicatorArity >= 0
2111
+ -> Name = IndicatorName,
2112
+ Arity = IndicatorArity
2113
+ ; repl_head_of(Pattern, HeadClause),
2114
+ repl_callable_indicator(HeadClause, Name, Arity)
2115
+ ).
2116
+
2117
+ %% repl_require_committable(+SessionId, +Term)
2118
+ repl_require_committable(_SessionId, Term) :-
2119
+ ( repl_protected_clause(Term)
2120
+ -> throw(error(permission_error(modify, reserved_name, Term),
2121
+ context(repl_define/4, 'cannot define a reserved predicate')))
2122
+ ; true
2123
+ ).
2124
+
2125
+ %% repl_require_not_reserved(+SessionId, +Indicator)
2126
+ repl_require_not_reserved(_SessionId, Name/Arity) :-
2127
+ ( repl_protected_name(Name)
2128
+ -> throw(error(permission_error(modify, reserved_name, Name/Arity),
2129
+ context(repl_define/4, 'cannot define a reserved predicate')))
2130
+ ; true
2131
+ ).
2132
+
1852
2133
  %% repl_extract_code(+Text, -Source)
1853
2134
  %% Extracts a ```dml fenced block; falls back to the whole answer.
1854
2135
  repl_extract_code(Text, Source) :-
@@ -2103,6 +2384,80 @@ repl_predicate_pure(SessionId, Name, Arity) :-
2103
2384
  functor(Head, Name, Arity),
2104
2385
  \+ ( clause(SessionId:Head, Body), repl_body_impure(Body) ).
2105
2386
 
2387
+ %% ============================================================
2388
+ %% Tool contract validation (end_turn advisory)
2389
+ %% ============================================================
2390
+ %%
2391
+ %% A tool head is what the nested agent calls; the tool body is what runs. So a
2392
+ %% well-formed tool is `tool(foo(A,B), "…") :- foo(A,B)` with foo/2 defined
2393
+ %% separately. The declared head arrives structurally through `clause/2` (its
2394
+ %% body is NOT executed), so a declared-but-undefined tool is caught here rather
2395
+ %% than at call time deep inside a nested agent.
2396
+ %%
2397
+ %% Known limitation: a module-qualified skill delegate (e.g. `other:solve(A,B)`)
2398
+ %% is not a session predicate and is reported; use `with_tools/2` for those.
2399
+
2400
+ %% repl_validate_tools(+SessionId, -Warnings)
2401
+ repl_validate_tools(SessionId, Warnings) :-
2402
+ findall(W, tool_contract_violation(SessionId, W), Raw),
2403
+ list_to_set(Raw, Warnings).
2404
+
2405
+ %% repl_validate_docs(+SessionId, -Warnings)
2406
+ %%
2407
+ %% `doc(Name/Arity, Text)` is an annotation: it documents a predicate, it does not
2408
+ %% create one. A `doc/2` whose predicate has no clauses therefore names a goal that
2409
+ %% fails silently when called — the failure surfaces far away as an empty result,
2410
+ %% and the name still appears in the manifest, so the listing looks healthy.
2411
+ %% Reported at `end_turn`, where the author can still see what they just wrote.
2412
+ repl_validate_docs(SessionId, Warnings) :-
2413
+ findall(W, doc_without_clauses(SessionId, W), Raw),
2414
+ list_to_set(Raw, Warnings).
2415
+
2416
+ doc_without_clauses(SessionId, Warning) :-
2417
+ SessionId:doc(Name/Arity, _),
2418
+ ( \+ current_predicate(SessionId:Name/Arity)
2419
+ ; functor(Head, Name, Arity),
2420
+ \+ clause(SessionId:Head, _)
2421
+ ),
2422
+ !,
2423
+ format(string(Warning),
2424
+ 'doc(~w/~w) documents a predicate with no clauses, so calling it fails silently and yields an empty result. Define ~w/~w, or remove the doc/2.',
2425
+ [Name, Arity, Name, Arity]).
2426
+
2427
+ tool_contract_violation(SessionId, Warning) :-
2428
+ clause(SessionId:tool(Head), Body),
2429
+ compound(Head),
2430
+ Head =.. [Name|HeadArgs],
2431
+ length(HeadArgs, Arity),
2432
+ tool_body_goal(Body, Call),
2433
+ Call =.. [Callee|Args],
2434
+ length(Args, CalleeArity),
2435
+ ( Callee == Name, CalleeArity == Arity, \+ repl_user_predicate(SessionId, Name, Arity)
2436
+ -> format(string(Warning),
2437
+ 'tool ~w/~w calls the tool itself, so a nested agent recurses forever. Define ~w/~w separately and delegate to it: tool(~w(A,B), "…") :- ~w(A,B).',
2438
+ [Name, Arity, Name, Name, Name, Name])
2439
+ ; Callee == Name, CalleeArity \== Arity
2440
+ -> format(string(Warning),
2441
+ '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.',
2442
+ [Name, Arity, Callee, CalleeArity])
2443
+ ; \+ ( SessionId:current_predicate(Callee/CalleeArity) )
2444
+ -> format(string(Warning),
2445
+ '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.',
2446
+ [Name, Arity, Callee, CalleeArity, Callee, CalleeArity])
2447
+ ).
2448
+
2449
+ %% tool_body_goal(+Body, -FirstCall): a tool body must reach a call, not be empty.
2450
+ tool_body_goal(Body, Call) :-
2451
+ Body \== true,
2452
+ tool_first_goal(Body, Call),
2453
+ compound(Call),
2454
+ functor(Call, F, _),
2455
+ F \== (',').
2456
+
2457
+ %% tool_first_goal(+Body, -Call): the first call in a conjunction body.
2458
+ tool_first_goal((A, _), Call) :- !, tool_first_goal(A, Call).
2459
+ tool_first_goal(Call, Call).
2460
+
2106
2461
  %% True when the predicate has at least one clause with a real body (not a fact).
2107
2462
  repl_predicate_has_rule(SessionId, Name, Arity) :-
2108
2463
  functor(Head, Name, Arity),
@@ -3313,7 +3668,8 @@ mi_call_prompt_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3313
3668
  add_trace_entry(SessionId, llm_call, prompt, [InterpDesc], Depth),
3314
3669
  % prompt/N is a simple LLM call - no tools, no tool scope
3315
3670
  get_model_spec(StateIn, ModelSpec),
3316
- engine_yield(request_agent_loop(InterpDesc, VarNames, [], [], none, ModelSpec)),
3671
+ resolve_schema_fields(SessionId, VarNames, ResolvedVarNames),
3672
+ engine_yield(request_agent_loop(InterpDesc, ResolvedVarNames, [], [], none, ModelSpec)),
3317
3673
  % Wait for agent_done (no tool calls expected since no tools provided)
3318
3674
  handle_agent_signals(SessionId, StateIn, StateOut),
3319
3675
  session_agent_result(SessionId, Result),
@@ -3331,7 +3687,7 @@ mi_call_prompt_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3331
3687
  engine_yield(output(WarnMsg)),
3332
3688
  fail
3333
3689
  ),
3334
- bind_task_variables(Result.variables, VarNames, Vars).
3690
+ bind_task_variables(SessionId, Result.variables, VarNames, Vars).
3335
3691
  % Don't modify memory - but state may have changed from tool calls
3336
3692
 
3337
3693
  mi_call_sample_token(Desc, Allowed, Token, StateIn, StateOut) :-
@@ -3594,7 +3950,8 @@ mi_call_task_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3594
3950
  get_reasoning_effort(StateIn, ReasoningEffort),
3595
3951
  get_recipe_context(StateIn, RecipeContext),
3596
3952
  get_model_spec(StateIn, ModelSpec),
3597
- engine_yield(request_agent_loop(InterpDesc, VarNames, UserTools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec)),
3953
+ resolve_schema_fields(SessionId, VarNames, ResolvedVarNames),
3954
+ engine_yield(request_agent_loop(InterpDesc, ResolvedVarNames, UserTools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec)),
3598
3955
  % Handle signals (tool calls) until agent_done
3599
3956
  handle_agent_signals(SessionId, StateIn, StateAfterAgent),
3600
3957
  session_agent_result(SessionId, Result),
@@ -3611,7 +3968,7 @@ mi_call_task_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3611
3968
  engine_yield(output(WarnMsg)),
3612
3969
  fail
3613
3970
  ),
3614
- bind_task_variables(Result.variables, VarNames, Vars),
3971
+ bind_task_variables(SessionId, Result.variables, VarNames, Vars),
3615
3972
  % Use full memory from agent loop result when available
3616
3973
  ( get_dict(full_memory, Result, FullMemory), is_list(FullMemory)
3617
3974
  -> set_memory(StateAfterAgent, FullMemory, StateOut),
@@ -3633,18 +3990,134 @@ mi_call_task_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3633
3990
  )
3634
3991
  ).
3635
3992
 
3993
+ %% ---------------------------------------------------------------
3994
+ %% Output schemas: schema(Name, [field(Field, Type, Option), ...])
3995
+ %%
3996
+ %% `list(object(B))` says "a list of dicts" but not what is in them, so every
3997
+ %% harness re-derives defaults and tolerates missing fields by hand. A schema
3998
+ %% declares the shape once, and `schema_fill/3` makes the value match it: absent
3999
+ %% fields with a default are filled, unknown fields are kept (so a model mistake
4000
+ %% stays visible rather than disappearing), and a `required` field that is absent
4001
+ %% or a field of the wrong type **fails the step** rather than yielding an empty
4002
+ %% result.
4003
+ %%
4004
+ %% schema(author, [
4005
+ %% field(name, string, required),
4006
+ %% field(affiliation, string, default("not stated")),
4007
+ %% field(email, string, default(""))
4008
+ %% ]).
4009
+ %%
4010
+ %% task("From the front matter extract every author: {Text}",
4011
+ %% list(schema(author, Authors))).
4012
+ %% schema_fill(+Fields, +Value, -Filled): the pure part, so it is testable and
4013
+ %% usable without a session. The session predicate schema_fill/3 looks the fields
4014
+ %% up from `schema/2`.
4015
+ schema_fill_fields(Fields, Value, Filled) :-
4016
+ is_dict(Value),
4017
+ ( member(field(Field, Type, required), Fields),
4018
+ \+ dict_field(Value, Field, _),
4019
+ !,
4020
+ format(atom(Msg), 'schema: required field "~w" is missing', [Field]),
4021
+ throw(error(schema_error(Msg), schema_fill/3))
4022
+ ; true
4023
+ ),
4024
+ ( member(field(Field, Type, _), Fields),
4025
+ dict_field(Value, Field, Found),
4026
+ \+ schema_value_ok(Type, Found),
4027
+ !,
4028
+ format(atom(Msg), 'schema: field "~w" is not ~w (~w)', [Field, Type, Found]),
4029
+ throw(error(schema_error(Msg), schema_fill/3))
4030
+ ; true
4031
+ ),
4032
+ dict_pairs(Value, _, Pairs),
4033
+ findall(Key-NewValue,
4034
+ ( member(Key-Old, Pairs),
4035
+ ( member(field(Key, _, Option), Fields)
4036
+ -> ( Option = default(Default), Old == "" -> NewValue = Default
4037
+ ; NewValue = Old
4038
+ )
4039
+ ; NewValue = Old % undeclared: kept, so it stays visible
4040
+ )
4041
+ ),
4042
+ Updated),
4043
+ findall(Field-Default,
4044
+ ( member(field(Field, _, default(Default)), Fields),
4045
+ \+ dict_field(Value, Field, _)
4046
+ ),
4047
+ Missing),
4048
+ append(Updated, Missing, All),
4049
+ dict_pairs(Filled, _, All).
4050
+
4051
+ %% A declared type accepts its own shape; anything coarser (a number for a string
4052
+ %% field, for instance) fails, because silently coercing hides the model's error.
4053
+ schema_value_ok(string, V) :- ( string(V) ; atom(V) ), !.
4054
+ schema_value_ok(integer, V) :- integer(V), !.
4055
+ schema_value_ok(number, V) :- number(V), !.
4056
+ schema_value_ok(boolean, V) :- ( V == true ; V == false ; V == yes ; V == no ), !.
4057
+ schema_value_ok(object, V) :- is_dict(V), !.
4058
+ schema_value_ok(array, V) :- is_list(V), !.
4059
+ schema_value_ok(_, _) :- fail.
4060
+
3636
4061
  %% bind_task_variables(+VarsDict, +Names, -Values)
3637
- bind_task_variables(_, [], []) :- !.
3638
- bind_task_variables(VarsDict, [N|Names], [Value|Values]) :-
4062
+ bind_task_variables(_, _, [], []) :- !.
4063
+ bind_task_variables(SessionId, VarsDict, [N|Names], [Value|Values]) :-
3639
4064
  ( is_dict(N)
3640
4065
  -> Key = N.name
3641
4066
  ; Key = N
3642
4067
  ),
3643
- ( get_dict(Key, VarsDict, Value)
4068
+ ( get_dict(Key, VarsDict, Raw)
3644
4069
  -> true
3645
- ; true
4070
+ ; Raw = _Missing
4071
+ ),
4072
+ ( is_dict(N), get_dict(schema, N, SchemaName), nonvar(Raw)
4073
+ -> schema_fields_of(SessionId, SchemaName, Fields),
4074
+ ( is_list(Raw)
4075
+ -> maplist(schema_filled(Fields), Raw, Value)
4076
+ ; schema_fill_fields(Fields, Raw, Value)
4077
+ )
4078
+ ; Value = Raw
3646
4079
  ),
3647
- bind_task_variables(VarsDict, Names, Values).
4080
+ bind_task_variables(SessionId, VarsDict, Names, Values).
4081
+
4082
+ schema_fields_of(SessionId, Name, Fields) :-
4083
+ ( catch(clause(SessionId:schema(Name, Fields), true), _, fail)
4084
+ -> true
4085
+ ; format(atom(Msg), 'schema "~w" is not declared', [Name]),
4086
+ throw(error(schema_error(Msg), schema_fields_of/3))
4087
+ ).
4088
+
4089
+ %% resolve_schema_fields(+SessionId, +VarNames, -Resolved)
4090
+ %%
4091
+ %% Attach a schema's field list to the descriptor for each output variable that
4092
+ %% names one, so the agent building the prompt can tell the model the shape to
4093
+ %% produce. Done here rather than in extract_var_info/4 (which runs at load time)
4094
+ %% because the `schema/2` fact is only certainly present once everything is loaded.
4095
+ resolve_schema_fields(SessionId, VarNames, Resolved) :-
4096
+ maplist(resolve_schema_field(SessionId), VarNames, Resolved).
4097
+
4098
+ resolve_schema_field(SessionId, VarName, Resolved) :-
4099
+ ( is_dict(VarName), get_dict(schema, VarName, SchemaName)
4100
+ -> ( schema_field_dicts(SessionId, SchemaName, Fields)
4101
+ -> put_dict(fields, VarName, Fields, Resolved)
4102
+ ; Resolved = VarName
4103
+ )
4104
+ ; Resolved = VarName
4105
+ ).
4106
+
4107
+ %% Field descriptors as dicts, so they cross the bridge without a decoder.
4108
+ %% `required` is 1/0 rather than an atom: the atom `false` arrives as the string
4109
+ %% "false", which is truthy in JavaScript.
4110
+ schema_field_dicts(SessionId, SchemaName, Dicts) :-
4111
+ catch(clause(SessionId:schema(SchemaName, Fields), true), _, fail),
4112
+ findall(x{name: Field, type: Type, required: Required, default: Default},
4113
+ ( member(field(Field, Type, Option), Fields),
4114
+ ( Option == required -> Required = 1 ; Required = 0 ),
4115
+ ( Option = default(D) -> Default = D ; Default = no_default )
4116
+ ),
4117
+ Dicts).
4118
+
4119
+ schema_filled(Fields, Raw, Filled) :-
4120
+ ( is_dict(Raw) -> schema_fill_fields(Fields, Raw, Filled) ; Filled = Raw ).
3648
4121
 
3649
4122
  %% ============================================================
3650
4123
  %% Exec Handling
@@ -3692,6 +4165,19 @@ mi_call(with_tools(ToolList, Goal), StateIn, StateOut) :-
3692
4165
  mi_call(Goal, ScopedState, StateWithScope),
3693
4166
  clear_tool_scope(StateWithScope, StateOut).
3694
4167
 
4168
+ %% mi_call(with_fresh_memory(Goal), +StateIn, -StateOut)
4169
+ %% Run Goal with an empty memory, and discard whatever it accumulates. The steps
4170
+ %% inside see nothing from the caller, and the caller sees nothing from them.
4171
+ %% Use it to scope a group of task/N calls that must not be influenced by the
4172
+ %% conversation so far, or to keep a long history from growing through a
4173
+ %% sub-computation. `prompt/N` is the single-call equivalent.
4174
+ mi_call(with_fresh_memory(Goal), StateIn, StateOut) :-
4175
+ !,
4176
+ get_memory(StateIn, SavedMemory),
4177
+ set_memory(StateIn, [], FreshState),
4178
+ mi_call(Goal, FreshState, AfterGoal),
4179
+ set_memory(AfterGoal, SavedMemory, StateOut).
4180
+
3695
4181
  %% mi_call(without_tools(ToolList, Goal), +StateIn, -StateOut)
3696
4182
  %% Run Goal with the specified tools excluded from nested tasks
3697
4183
  mi_call(without_tools(ToolList, Goal), StateIn, StateOut) :-
@@ -3786,15 +4272,12 @@ mi_call(save_image(Media, Out), StateIn, StateIn) :-
3786
4272
  media_dict(Media, Dict),
3787
4273
  ( get_dict(kind, Dict, inline_svg),
3788
4274
  get_dict(data, Dict, Svg)
3789
- -> resolve_workspace_path(Out, OutAbs),
3790
- open(OutAbs, write, Stream),
4275
+ -> open(Out, write, Stream),
3791
4276
  write(Stream, Svg),
3792
4277
  close(Stream)
3793
4278
  ; get_dict(path, Dict, Path),
3794
4279
  Path \== ""
3795
- -> resolve_workspace_path(Path, InAbs),
3796
- resolve_workspace_path(Out, OutAbs),
3797
- copy_file(InAbs, OutAbs)
4280
+ -> copy_file(Path, Out)
3798
4281
  ; throw(error(domain_error(save_image, Media), save_image/2))
3799
4282
  ).
3800
4283
 
@@ -4284,28 +4767,428 @@ mi_foldl(Goal, [H|T], V0, V, StateIn, StateOut) :-
4284
4767
  mi_foldl(Goal, T, V1, V, State1, StateOut).
4285
4768
 
4286
4769
  %% ============================================================
4287
- %% File I/O - All paths relative to /workspace
4770
+ %% Workspace predicates (one definition, every surface)
4288
4771
  %% ============================================================
4772
+ %%
4773
+ %% These are **real clauses in the session module**, not only meta-interpreter
4774
+ %% handlers. `read_file/2` in particular has no other definition in this build:
4775
+ %% without these clauses it exists only as an `mi_call/3` body, so it works from
4776
+ %% DML but fails from a host query. Installing them makes a host query, `execute`,
4777
+ %% `/goal` and a nested `task/N` resolve the same predicate.
4778
+ %%
4779
+ %% The file-stream primitives live in `system:` (NOT `readutil`, which has no
4780
+ %% `read_file_to_string/3`). They are module-qualified so a wrapper cannot
4781
+ %% recurse into itself.
4782
+ %%
4783
+ %% Deliberately not here: `open/3`, `close/1`, `read_string/3`, `write/2`. They
4784
+ %% are imported builtins in the session module and SWI refuses to shadow an
4785
+ %% imported private predicate, so a workspace-resolving version cannot be
4786
+ %% installed. `read_file/2` and `write_file/2` are the whole-file API instead.
4787
+ %%
4788
+ %% `workspace_predicate_name/1` is the single source of truth; it also keeps the
4789
+ %% set out of `listing` / `export` / history.
4790
+
4791
+ workspace_predicate_name(read_file).
4792
+ workspace_predicate_name(read_file_to_string).
4793
+ workspace_predicate_name(write_file).
4794
+ workspace_predicate_name(append_file).
4795
+ workspace_predicate_name(exists_file).
4796
+ workspace_predicate_name(exists_directory).
4797
+ workspace_predicate_name(directory_files).
4798
+ workspace_predicate_name(make_directory).
4799
+ workspace_predicate_name(make_directory_path).
4800
+ workspace_predicate_name(delete_file).
4801
+ workspace_predicate_name(delete_directory).
4802
+ workspace_predicate_name(size_file).
4803
+ workspace_predicate_name(time_file).
4804
+ workspace_predicate_name(dict_field).
4805
+ workspace_predicate_name(dict_field_key).
4806
+ workspace_predicate_name(json_get).
4807
+ workspace_predicate_name(json_gets).
4808
+ workspace_predicate_name(json_put).
4809
+ workspace_predicate_name(json_text).
4810
+ workspace_predicate_name(schema_fill).
4811
+ workspace_predicate_name(pattern).
4812
+
4813
+ %% dict_field(+Dict, +Key, -Value) and dict_field(+Dict, +Key, +Default, -Value)
4814
+ %%
4815
+ %% Look up a field of a model-produced dict. SWI dicts have *atom* keys and
4816
+ %% `get_dict/3` is strict: `get_dict("affiliation", D)` raises
4817
+ %% `type_error(dict-key, affiliation)` rather than failing, and the model varies
4818
+ %% the spelling (`Name` / `name` / `full_name`). This matches on a normalised
4819
+ %% atom — any of atom/string, any case, `_`/`-`/space ignored — so
4820
+ %% `dict_field(D, "affiliation", A)` finds the `affiliation` key.
4821
+ dict_field(Dict, Key, Value) :-
4822
+ is_dict(Dict),
4823
+ dict_field_key(Key, Wanted),
4824
+ dict_pairs(Dict, _, Pairs),
4825
+ member(K-V, Pairs),
4826
+ dict_field_key(K, Wanted),
4827
+ Value = V.
4828
+
4829
+ dict_field(Dict, Key, Default, Value) :-
4830
+ ( dict_field(Dict, Key, Found)
4831
+ -> Value = Found
4832
+ ; Value = Default
4833
+ ).
4834
+
4835
+ %% dict_field_key(+Key, -Normalised): lowercase atom with separators removed.
4836
+ dict_field_key(Key, Normalised) :-
4837
+ ( atom(Key) -> atom_string(Key, Text) ; Text = Key ),
4838
+ string_lower(Text, Lower),
4839
+ split_string(Lower, " _-", "", Parts),
4840
+ atomic_list_concat(Parts, '', Normalised).
4841
+
4842
+ %% json_get/3,4, json_gets/3, json_put/4, json_text/2
4843
+ %%
4844
+ %% Path access into a model-produced structure, so a nested lookup is one call
4845
+ %% rather than a chain of dict_field/3. A path is a list of keys and indices, or a
4846
+ %% string/atom with `.` separators (`"results.0.authors.1.name"`), which is how a
4847
+ %% JSON path is normally written.
4848
+ %%
4849
+ %% A missing path **fails** — it does not throw and does not yield "" — so absence
4850
+ %% is visible at the call site; json_get/4 supplies a default instead. Key matching
4851
+ %% is the same tolerant match as dict_field/3 (atom or string, any case,
4852
+ %% `_`/`-`/space ignored).
4853
+ json_path_segments(Path, Segments) :-
4854
+ ( is_list(Path)
4855
+ -> Segments = Path
4856
+ ; ( atom(Path) -> atom_string(Path, Text) ; Text = Path ),
4857
+ split_string(Text, ".", "", RawParts),
4858
+ exclude(==(""), RawParts, Parts),
4859
+ maplist(json_path_segment, Parts, Segments)
4860
+ ).
4861
+
4862
+ json_path_segment("*", star) :- !.
4863
+ json_path_segment(Part, Index) :-
4864
+ catch(number_string(N, Part), _, fail),
4865
+ integer(N),
4866
+ !,
4867
+ Index = N.
4868
+ json_path_segment(Part, Part).
4869
+
4870
+ %% json_walk(+Value, +Segments, -Found)
4871
+ json_walk(Value, [], Value) :- !.
4872
+ json_walk(Doc, [Index|Rest], Found) :-
4873
+ integer(Index),
4874
+ is_list(Doc),
4875
+ !,
4876
+ nth0(Index, Doc, Next),
4877
+ json_walk(Next, Rest, Found).
4878
+ json_walk(Doc, [Key|Rest], Found) :-
4879
+ is_dict(Doc),
4880
+ dict_field_key(Key, Wanted),
4881
+ dict_pairs(Doc, _, Pairs),
4882
+ member(K-V, Pairs),
4883
+ dict_field_key(K, Wanted),
4884
+ json_walk(V, Rest, Found).
4885
+
4886
+ json_get(Doc, Path, Value) :-
4887
+ json_path_segments(Path, Segments),
4888
+ \+ member(star, Segments),
4889
+ json_walk(Doc, Segments, Value).
4890
+
4891
+ json_get(Doc, Path, Default, Value) :-
4892
+ ( json_get(Doc, Path, Found)
4893
+ -> Value = Found
4894
+ ; Value = Default
4895
+ ).
4896
+
4897
+ %% json_gets/3: `*` maps over a list, collecting the remainder for every element.
4898
+ %% Nested `*`s flatten, so "results.*.authors.*.name" is a flat list of names.
4899
+ json_gets(Doc, Path, Values) :-
4900
+ json_path_segments(Path, Segments),
4901
+ json_collect(Doc, Segments, Values).
4902
+
4903
+ json_collect(Value, [], [Value]) :- !.
4904
+ json_collect(Doc, [star|Rest], Values) :-
4905
+ is_list(Doc),
4906
+ findall(Collected,
4907
+ ( member(Item, Doc), json_collect(Item, Rest, Collected) ),
4908
+ Nested),
4909
+ append(Nested, Values).
4910
+ json_collect(Doc, [Seg|Rest], Values) :-
4911
+ json_walk_1(Doc, Seg, Next),
4912
+ json_collect(Next, Rest, Values).
4913
+
4914
+ %% One step of a walk, for the collector (where `star` is handled by the caller).
4915
+ json_walk_1(Doc, Index, Value) :- integer(Index), is_list(Doc), !, nth0(Index, Doc, Value).
4916
+ json_walk_1(Doc, Key, Value) :-
4917
+ is_dict(Doc),
4918
+ dict_field_key(Key, Wanted),
4919
+ dict_pairs(Doc, _, Pairs),
4920
+ member(K-V, Pairs),
4921
+ dict_field_key(K, Wanted),
4922
+ Value = V.
4923
+
4924
+ %% json_put/4: set a key path. Lists are read-only here — an index path is
4925
+ %% rejected, because building output is about setting fields, not splicing lists.
4926
+ json_put(Doc, Path, Value, Updated) :-
4927
+ json_path_segments(Path, Segments),
4928
+ Segments \== [],
4929
+ \+ member(star, Segments),
4930
+ % NB: `\+ ( member(S, Segments), integer(S) )`. Writing this as
4931
+ % `\+ member(S, Segments), integer(S)` binds S inside the negation, so the
4932
+ % whole guard fails for any non-empty path.
4933
+ \+ ( member(Seg, Segments), integer(Seg) ),
4934
+ json_put_walk(Doc, Segments, Value, Updated).
4935
+
4936
+ json_put_walk(_Doc, [], Value, Value) :- !.
4937
+ json_put_walk(Doc, [Key|Rest], Value, Updated) :-
4938
+ is_dict(Doc),
4939
+ ( json_walk_1(Doc, Key, Old)
4940
+ -> json_put_walk(Old, Rest, Value, NewValue)
4941
+ ; json_put_walk(_{}, Rest, Value, NewValue)
4942
+ ),
4943
+ dict_field_key(Key, Key0),
4944
+ put_dict(Key0, Doc, NewValue, Updated).
4945
+
4946
+ %% json_text(+Dict, -Text): compact JSON. For layout or streaming use
4947
+ %% json_write_dict/3 from library(http/json).
4948
+ json_text(Dict, Text) :- atom_json_dict(Text, Dict, []).
4949
+
4950
+ %% pattern(+Template, ?Text, ?Bindings)
4951
+ %%
4952
+ %% One syntax for building a string and for parsing it. Literal text matches
4953
+ %% exactly, and `$Name` marks a slot; the direction follows from what is bound:
4954
+ %%
4955
+ %% pattern("Name: $Name, age: $Age", Text, [name="Ada", age=36]) % build
4956
+ %% pattern("Name: $Name, age: $Age", "Name: Ada, age: 36", B) % parse
4957
+ %% pattern("DOI: $Doi:doi", Text, [doi=D]) % typed slot
4958
+ %%
4959
+ %% `$` is the marker rather than `{…}` so a template can contain JSON or any other
4960
+ %% brace-bearing text verbatim, and so it never collides with the `{Var}`
4961
+ %% interpolation used in task/N descriptions. `$$` is a literal dollar.
4962
+ %%
4963
+ %% A template must be anchored by literal text: two slots separated only by nothing
4964
+ %% (`"$A$B"`) is rejected, rather than resolved arbitrarily. On parse a slot
4965
+ %% consumes up to the next literal, so the literals decide the split.
4966
+ pattern(Template, Text, Bindings) :-
4967
+ pattern_tokens(Template, Tokens),
4968
+ ( var(Text)
4969
+ -> pattern_build(Tokens, Bindings, Text)
4970
+ ; pattern_parse(Tokens, Text, Bindings)
4971
+ ).
4972
+
4973
+ %% pattern_tokens(+Template, -Tokens): [lit(CharList)|slot(NameChars, TypeChars)]
4974
+ pattern_tokens(Template, Tokens) :-
4975
+ ( atom(Template) -> atom_string(Template, Text) ; Text = Template ),
4976
+ string_chars(Text, Chars),
4977
+ pattern_scan(Chars, [], Tokens),
4978
+ pattern_check_anchored(Tokens).
4979
+
4980
+ pattern_scan([], Acc, [lit(Text)]) :-
4981
+ !,
4982
+ reverse(Acc, Text).
4983
+ pattern_scan(['$', '$' | Rest], Acc, Tokens) :-
4984
+ !,
4985
+ pattern_scan(Rest, ['$' | Acc], Tokens).
4986
+ pattern_scan(['$' | Rest], Acc, Tokens) :-
4987
+ !,
4988
+ pattern_take_name(Rest, NameChars, Rest1),
4989
+ NameChars \== [],
4990
+ atom_chars(Name, NameChars),
4991
+ ( Rest1 = [':' | Rest2],
4992
+ pattern_take_name(Rest2, TypeChars, Rest3),
4993
+ TypeChars \== []
4994
+ -> atom_chars(Type, TypeChars)
4995
+ ; Rest3 = Rest1, Type = none
4996
+ ),
4997
+ reverse(Acc, Text),
4998
+ Tokens = [lit(Text), slot(Name, Type) | More],
4999
+ pattern_scan(Rest3, [], More).
5000
+ pattern_scan([C | Rest], Acc, Tokens) :-
5001
+ pattern_scan(Rest, [C | Acc], Tokens).
5002
+
5003
+ %% A slot name is a run of characters that are not a template delimiter. Scanning by
5004
+ %% delimiter rather than by `char_type(C, alnum)` avoids depending on a ctypes
5005
+ %% predicate that is not guaranteed in this build.
5006
+ pattern_take_name([C | Rest], [C | More], Left) :-
5007
+ \+ pattern_delimiter(C),
5008
+ !,
5009
+ pattern_take_name(Rest, More, Left).
5010
+ pattern_take_name(Rest, [], Rest).
5011
+
5012
+ pattern_delimiter(' '). pattern_delimiter('\t'). pattern_delimiter('\n').
5013
+ pattern_delimiter(':'). pattern_delimiter(','). pattern_delimiter('.').
5014
+ pattern_delimiter(')'). pattern_delimiter(']'). pattern_delimiter('}').
5015
+ pattern_delimiter('$'). pattern_delimiter('-'). pattern_delimiter('/').
5016
+ pattern_delimiter('='). pattern_delimiter('\'').
5017
+
5018
+ %% Two slots with nothing between them cannot be told apart on parse.
5019
+ pattern_check_anchored(Tokens) :-
5020
+ pattern_adjacent_slots(Tokens, A, B),
5021
+ !,
5022
+ format(atom(Msg),
5023
+ 'pattern: slots "~w" and "~w" are adjacent with no literal between them, so the split is ambiguous',
5024
+ [A, B]),
5025
+ throw(error(domain_error(pattern_unanchored, Msg), pattern_tokens/2)).
5026
+ pattern_check_anchored(_).
5027
+
5028
+ pattern_adjacent_slots([slot(A, _), lit([]), slot(B, _) | _], A, B) :- !.
5029
+ pattern_adjacent_slots([_ | Rest], A, B) :- pattern_adjacent_slots(Rest, A, B).
5030
+
5031
+ %% pattern_build(+Tokens, +Bindings, -Text)
5032
+ pattern_build(Tokens, Bindings, Text) :-
5033
+ findall(Chars,
5034
+ ( member(Token, Tokens),
5035
+ pattern_build_token(Token, Bindings, Chars)
5036
+ ),
5037
+ Parts),
5038
+ append(Parts, All),
5039
+ string_chars(Text, All).
4289
5040
 
4290
- %% Helper: resolve path relative to /workspace
4291
- %% Prevents directory traversal outside workspace
4292
- resolve_workspace_path(RelPath, FullPath) :-
4293
- ( atom(RelPath) -> atom_string(RelPath, RelPathStr)
4294
- ; RelPathStr = RelPath
5041
+ pattern_build_token(lit(Chars), _, Chars) :- !.
5042
+ pattern_build_token(slot(Name, Type), Bindings, Chars) :-
5043
+ ( member(Name=Value, Bindings)
5044
+ -> true
5045
+ ; member(Key=Value, Bindings), pattern_name(Key, Name)
5046
+ -> true
5047
+ ; format(atom(Msg), 'pattern: no value bound for slot "~w"', [Name]),
5048
+ throw(error(uninstantiation_error(Msg), pattern/3))
4295
5049
  ),
4296
- % Remove leading slashes to make relative
4297
- ( sub_string(RelPathStr, 0, 1, _, "/")
4298
- -> sub_string(RelPathStr, 1, _, 0, CleanPath)
4299
- ; CleanPath = RelPathStr
5050
+ pattern_render(Type, Value, Chars).
5051
+
5052
+ pattern_render(none, Value, Chars) :- !, pattern_text_chars(Value, Chars).
5053
+ pattern_render(Type, Value, Chars) :-
5054
+ ( memberchk(Type, [string, text])
5055
+ -> pattern_text_chars(Value, Chars)
5056
+ ; memberchk(Type, [int, integer, number])
5057
+ -> ( number(Value) -> format(string(S), "~w", [Value]) ; S = Value ),
5058
+ string_chars(S, Chars)
5059
+ ; format(atom(Msg), 'pattern: unknown slot type "~w"', [Type]),
5060
+ throw(error(domain_error(pattern_type, Msg), pattern/3))
5061
+ ).
5062
+
5063
+ %% Always a char LIST. Returning the string itself here silently corrupted the
5064
+ %% build: the parts list then held a string among char lists, and the final
5065
+ %% string_chars/2 failed on a mixed list.
5066
+ pattern_text_chars(Value, Chars) :-
5067
+ ( string(Value) -> string_chars(Value, Chars)
5068
+ ; atom(Value) -> atom_chars(Value, Chars)
5069
+ ; number(Value) -> format(string(S), "~w", [Value]), string_chars(S, Chars)
5070
+ ; format(atom(Msg), 'pattern: cannot render ~w as text', [Value]),
5071
+ throw(error(type_error(text, Msg), pattern/3))
5072
+ ).
5073
+
5074
+ %% pattern_parse(+Tokens, +Text, -Bindings)
5075
+ pattern_parse(Tokens, Text, Bindings) :-
5076
+ % Always a char list: a string here would be one element and the literal
5077
+ % matching below would never line up.
5078
+ ( string(Text) -> string_chars(Text, Chars)
5079
+ ; atom(Text) -> atom_string(Text, S), string_chars(S, Chars)
5080
+ ; Chars = Text
4300
5081
  ),
4301
- % Prevent directory traversal
4302
- ( sub_string(CleanPath, _, _, _, "..")
4303
- -> throw(error(permission_error(access, directory, RelPath),
4304
- context(resolve_workspace_path/2, 'Directory traversal not allowed')))
4305
- ; true
5082
+ is_list(Chars),
5083
+ pattern_match(Tokens, Chars, Bindings).
5084
+
5085
+ pattern_match([], [], []) :- !.
5086
+ pattern_match([lit(Lit)], Rest, []) :-
5087
+ !,
5088
+ append(Lit, [], Rest).
5089
+ pattern_match([lit(Lit), slot(Name, Type) | More], Chars, [Name=Value | Bindings]) :-
5090
+ append(Lit, Rest0, Chars),
5091
+ !,
5092
+ ( More = [lit(Next) | _], Next \== []
5093
+ -> append(Taken, After, Rest0),
5094
+ append(Next, _, After)
5095
+ ; Taken = Rest0,
5096
+ After = []
4306
5097
  ),
4307
- % Build full path
4308
- atom_concat('/workspace/', CleanPath, FullPath).
5098
+ pattern_capture(Type, Taken, Value),
5099
+ pattern_match(More, After, Bindings).
5100
+
5101
+ pattern_capture(none, Chars, Value) :- !, string_chars(Value, Chars).
5102
+ pattern_capture(Type, Chars, Value) :-
5103
+ ( memberchk(Type, [string, text])
5104
+ -> string_chars(Value, Chars)
5105
+ ; memberchk(Type, [int, integer])
5106
+ -> ( catch(number_codes(N, Chars), _, fail), integer(N)
5107
+ -> Value = N
5108
+ ; format(atom(Msg), 'pattern: "~s" is not an integer', [Chars]),
5109
+ throw(error(syntax_error(Msg), pattern/3))
5110
+ )
5111
+ ; memberchk(Type, [number])
5112
+ -> ( catch(number_codes(N, Chars), _, fail)
5113
+ -> Value = N
5114
+ ; format(atom(Msg), 'pattern: "~s" is not a number', [Chars]),
5115
+ throw(error(syntax_error(Msg), pattern/3))
5116
+ )
5117
+ ; format(atom(Msg), 'pattern: unknown slot type "~w"', [Type]),
5118
+ throw(error(domain_error(pattern_type, Msg), pattern/3))
5119
+ ).
5120
+
5121
+ %% pattern_name(+Key, +Name): compare a template slot name with a binding key the
5122
+ %% way dict_field does — atom or string, any case, separators ignored.
5123
+ pattern_name(Key, Name) :-
5124
+ pattern_normalise(Key, A),
5125
+ pattern_normalise(Name, B),
5126
+ A == B.
5127
+
5128
+ pattern_normalise(Value, Normalised) :-
5129
+ ( atom(Value) -> atom_string(Value, Text) ; Text = Value ),
5130
+ string_lower(Text, Lower),
5131
+ split_string(Lower, " _-", "", Parts),
5132
+ atomic_list_concat(Parts, '', Normalised).
5133
+
5134
+ %% repl_inject_workspace_predicates(+SessionId)
5135
+ %% Idempotent: assertz/1 the same clauses twice is harmless (a duplicate clause
5136
+ %% yields the same solution once? no - it yields twice), so clear first.
5137
+ repl_inject_workspace_predicates(SessionId) :-
5138
+ forall(( workspace_predicate_name(Name), between(0, 5, Arity) ),
5139
+ catch(abolish(SessionId:Name/Arity), _, true)),
5140
+ forall(workspace_predicate_clause(SessionId, Clause),
5141
+ catch(assertz(Clause), _, true)).
5142
+
5143
+ workspace_predicate_clause(S, (S:read_file(F, C) :-
5144
+ system:read_file_to_string(F, C, []))).
5145
+ workspace_predicate_clause(S, (S:read_file_to_string(F, C, O) :-
5146
+ system:read_file_to_string(F, C, O))).
5147
+ workspace_predicate_clause(S, (S:write_file(F, C) :-
5148
+ system:open(F, write, St), system:write(St, C), system:close(St))).
5149
+ workspace_predicate_clause(S, (S:append_file(F, C) :-
5150
+ system:open(F, append, St), system:write(St, C), system:close(St))).
5151
+ workspace_predicate_clause(S, (S:exists_file(F) :-
5152
+ system:exists_file(F))).
5153
+ workspace_predicate_clause(S, (S:exists_directory(D) :-
5154
+ system:exists_directory(D))).
5155
+ workspace_predicate_clause(S, (S:directory_files(D, L) :-
5156
+ system:directory_files(D, L))).
5157
+ workspace_predicate_clause(S, (S:make_directory(D) :-
5158
+ system:make_directory(D))).
5159
+ workspace_predicate_clause(S, (S:make_directory_path(D) :-
5160
+ system:make_directory_path(D))).
5161
+ workspace_predicate_clause(S, (S:delete_file(F) :-
5162
+ system:delete_file(F))).
5163
+ workspace_predicate_clause(S, (S:delete_directory(D) :-
5164
+ system:delete_directory(D))).
5165
+ workspace_predicate_clause(S, (S:size_file(F, Sz) :-
5166
+ system:size_file(F, Sz))).
5167
+ workspace_predicate_clause(S, (S:dict_field(D, K, V) :- deepclause_mi:dict_field(D, K, V))).
5168
+ workspace_predicate_clause(S, (S:dict_field(D, K, Def, V) :- deepclause_mi:dict_field(D, K, Def, V))).
5169
+ workspace_predicate_clause(S, (S:dict_field_key(K, N) :- deepclause_mi:dict_field_key(K, N))).
5170
+ workspace_predicate_clause(S, (S:json_get(D, P, V) :- deepclause_mi:json_get(D, P, V))).
5171
+ workspace_predicate_clause(S, (S:json_get(D, P, Def, V) :- deepclause_mi:json_get(D, P, Def, V))).
5172
+ workspace_predicate_clause(S, (S:json_gets(D, P, Vs) :- deepclause_mi:json_gets(D, P, Vs))).
5173
+ workspace_predicate_clause(S, (S:json_put(D, P, V, D2) :- deepclause_mi:json_put(D, P, V, D2))).
5174
+ workspace_predicate_clause(S, (S:json_text(D, T) :- deepclause_mi:json_text(D, T))).
5175
+ workspace_predicate_clause(S, (S:schema_fill(N, V, F) :-
5176
+ deepclause_mi:schema_fields_of(S, N, Fields), deepclause_mi:schema_fill_fields(Fields, V, F))).
5177
+ workspace_predicate_clause(S, (S:pattern(T, Text, B) :- deepclause_mi:pattern(T, Text, B))).
5178
+
5179
+ workspace_predicate_clause(S, (S:time_file(F, T) :-
5180
+ system:time_file(F, T))).
5181
+
5182
+ %% ============================================================
5183
+ %% File I/O - paths resolve against /workspace (see the note above)
5184
+ %% ============================================================
5185
+
5186
+ %% File I/O paths
5187
+ %% `/workspace` is the host project, mounted by the WASM filesystem (NODEFS);
5188
+ %% its working directory is set to /workspace, so a relative path and a
5189
+ %% `/workspace/...` path name the same file. Every other absolute path is the
5190
+ %% ephemeral in-memory VM filesystem, not the host. There is no rewriting
5191
+ %% layer any more: the mount is the boundary.
4309
5192
 
4310
5193
  %% mi_call(consult(File), +StateIn, -StateOut)
4311
5194
  %% Load Prolog clauses from a file into the current session module
@@ -4314,8 +5197,7 @@ mi_call(consult(File), StateIn, StateIn) :-
4314
5197
  !,
4315
5198
  get_session_id(SessionId),
4316
5199
  guard_not_frozen(SessionId),
4317
- resolve_workspace_path(File, FullPath),
4318
- readutil:read_file_to_string(FullPath, Code, []),
5200
+ system:read_file_to_string(File, Code, []),
4319
5201
  consult_string(Code, SessionId).
4320
5202
 
4321
5203
  mi_call(use_module(Module), StateIn, StateIn) :-
@@ -4413,22 +5295,19 @@ consult_process_term(Head, SessionId) :-
4413
5295
  %% mi_call(read_file_to_string(File, Content, Options), +StateIn, -StateOut)
4414
5296
  mi_call(read_file_to_string(File, Content, Options), StateIn, StateIn) :-
4415
5297
  !,
4416
- resolve_workspace_path(File, FullPath),
4417
- readutil:read_file_to_string(FullPath, Content, Options).
5298
+ system:read_file_to_string(File, Content, Options).
4418
5299
 
4419
5300
  %% mi_call(read_file(File, Content), +StateIn, -StateOut)
4420
5301
  %% Simplified version without options
4421
5302
  mi_call(read_file(File, Content), StateIn, StateIn) :-
4422
5303
  !,
4423
- resolve_workspace_path(File, FullPath),
4424
- readutil:read_file_to_string(FullPath, Content, []).
5304
+ system:read_file_to_string(File, Content, []).
4425
5305
 
4426
5306
  %% mi_call(write_file(File, Content), +StateIn, -StateOut)
4427
5307
  %% Write string content to a file
4428
5308
  mi_call(write_file(File, Content), StateIn, StateIn) :-
4429
5309
  !,
4430
- resolve_workspace_path(File, FullPath),
4431
- open(FullPath, write, Stream),
5310
+ open(File, write, Stream),
4432
5311
  write(Stream, Content),
4433
5312
  close(Stream).
4434
5313
 
@@ -4436,8 +5315,7 @@ mi_call(write_file(File, Content), StateIn, StateIn) :-
4436
5315
  %% Append string content to a file
4437
5316
  mi_call(append_file(File, Content), StateIn, StateIn) :-
4438
5317
  !,
4439
- resolve_workspace_path(File, FullPath),
4440
- open(FullPath, append, Stream),
5318
+ open(File, append, Stream),
4441
5319
  write(Stream, Content),
4442
5320
  close(Stream).
4443
5321
 
@@ -4445,19 +5323,13 @@ mi_call(append_file(File, Content), StateIn, StateIn) :-
4445
5323
  %% Open a file for reading/writing
4446
5324
  mi_call(open(File, Mode, Stream), StateIn, StateIn) :-
4447
5325
  !,
4448
- resolve_workspace_path(File, FullPath),
4449
- open(FullPath, Mode, Stream).
5326
+ open(File, Mode, Stream).
4450
5327
 
4451
5328
  %% mi_call(close(Stream), +StateIn, -StateOut)
4452
5329
  mi_call(close(Stream), StateIn, StateIn) :-
4453
5330
  !,
4454
5331
  close(Stream).
4455
5332
 
4456
- %% mi_call(read_string(Stream, Length, Content), +StateIn, -StateOut)
4457
- mi_call(read_string(Stream, Length, Content), StateIn, StateIn) :-
4458
- !,
4459
- read_string(Stream, Length, Content).
4460
-
4461
5333
  %% mi_call(write(Stream, Content), +StateIn, -StateOut)
4462
5334
  mi_call(write(Stream, Content), StateIn, StateIn) :-
4463
5335
  !,
@@ -4466,60 +5338,51 @@ mi_call(write(Stream, Content), StateIn, StateIn) :-
4466
5338
  %% mi_call(exists_file(File), +StateIn, -StateOut)
4467
5339
  mi_call(exists_file(File), StateIn, StateIn) :-
4468
5340
  !,
4469
- resolve_workspace_path(File, FullPath),
4470
- exists_file(FullPath).
5341
+ exists_file(File).
4471
5342
 
4472
5343
  %% mi_call(exists_directory(Dir), +StateIn, -StateOut)
4473
5344
  mi_call(exists_directory(Dir), StateIn, StateIn) :-
4474
5345
  !,
4475
- resolve_workspace_path(Dir, FullPath),
4476
- exists_directory(FullPath).
5346
+ exists_directory(Dir).
4477
5347
 
4478
5348
  %% mi_call(directory_files(Dir, Files), +StateIn, -StateOut)
4479
5349
  mi_call(directory_files(Dir, Files), StateIn, StateIn) :-
4480
5350
  !,
4481
- resolve_workspace_path(Dir, FullPath),
4482
- directory_files(FullPath, Files).
5351
+ directory_files(Dir, Files).
4483
5352
 
4484
5353
  %% mi_call(make_directory(Dir), +StateIn, -StateOut)
4485
5354
  mi_call(make_directory(Dir), StateIn, StateIn) :-
4486
5355
  !,
4487
- resolve_workspace_path(Dir, FullPath),
4488
- make_directory(FullPath).
5356
+ make_directory(Dir).
4489
5357
 
4490
5358
  %% mi_call(make_directory_path(Dir), +StateIn, -StateOut)
4491
5359
  mi_call(make_directory_path(Dir), StateIn, StateIn) :-
4492
5360
  !,
4493
- resolve_workspace_path(Dir, FullPath),
4494
- make_directory_path(FullPath).
5361
+ make_directory_path(Dir).
4495
5362
 
4496
5363
  %% mi_call(delete_file(File), +StateIn, -StateOut)
4497
5364
  mi_call(delete_file(File), StateIn, StateIn) :-
4498
5365
  !,
4499
- resolve_workspace_path(File, FullPath),
4500
- delete_file(FullPath).
5366
+ delete_file(File).
4501
5367
 
4502
5368
  %% mi_call(delete_directory(Dir), +StateIn, -StateOut)
4503
5369
  mi_call(delete_directory(Dir), StateIn, StateIn) :-
4504
5370
  !,
4505
- resolve_workspace_path(Dir, FullPath),
4506
- delete_directory(FullPath).
5371
+ delete_directory(Dir).
4507
5372
 
4508
5373
  %% mi_call(size_file(File, Size), +StateIn, -StateOut)
4509
5374
  %% size_file/2 is a plain SWI builtin, so it would otherwise resolve `File`
4510
5375
  %% against the WASM process cwd (not the workspace) and demand `/workspace/...`.
4511
5376
  mi_call(size_file(File, Size), StateIn, StateIn) :-
4512
5377
  !,
4513
- resolve_workspace_path(File, FullPath),
4514
- size_file(FullPath, Size).
5378
+ size_file(File, Size).
4515
5379
 
4516
5380
  %% mi_call(time_file(File, Time), +StateIn, -StateOut)
4517
5381
  %% Modifications time (seconds since epoch); same workspace-relative rule as
4518
5382
  %% size_file/2.
4519
5383
  mi_call(time_file(File, Time), StateIn, StateIn) :-
4520
5384
  !,
4521
- resolve_workspace_path(File, FullPath),
4522
- time_file(FullPath, Time).
5385
+ time_file(File, Time).
4523
5386
 
4524
5387
  %% ============================================================
4525
5388
  %% Catch/Throw - STATE THREADED
@@ -4625,6 +5488,7 @@ is_mi_special_predicate(prompt_named(_,_,_)). %% Transformed form of prompt/N
4625
5488
  is_mi_special_predicate(llm(_,_)).
4626
5489
  %% Tool scoping predicates
4627
5490
  is_mi_special_predicate(with_tools(_,_)).
5491
+ is_mi_special_predicate(with_fresh_memory(_)).
4628
5492
  is_mi_special_predicate(without_tools(_,_)).
4629
5493
  %% Reasoning effort scoping
4630
5494
  is_mi_special_predicate(with_reasoning(_,_)).
@@ -4856,6 +5720,14 @@ consume_gas(StateIn, StateOut) :-
4856
5720
  %% mi_call_dispatch(+Goal, +StateIn, -StateOut)
4857
5721
  %% Dispatch user-defined vs built-in predicates
4858
5722
  %% Uses separate clauses instead of if-then-else to preserve backtracking
5723
+ mi_call_dispatch(Goal, StateIn, StateIn) :-
5724
+ get_session_id(SessionId),
5725
+ callable(Goal),
5726
+ predicate_property(SessionId:Goal, defined),
5727
+ predicate_property(Goal, built_in),
5728
+ !,
5729
+ call(SessionId:Goal).
5730
+
4859
5731
  mi_call_dispatch(Goal, StateIn, StateIn) :-
4860
5732
  predicate_property(Goal, built_in),
4861
5733
  !, % Commit: it's built-in, no backtracking needed
@@ -4937,8 +5809,14 @@ var_to_name(Bindings, Var, Name) :-
4937
5809
  %% expand_interpolations(+Term, +Bindings, -ExpandedTerm)
4938
5810
  %% Walk a term and expand strings containing {VarName} patterns
4939
5811
  %% Bindings is a list of 'VarName'=Variable pairs from read_term
5812
+ %% Interpolation happens in goal position only: `task/N`/`prompt/N`/`output/1`/… and
5813
+ %% the arguments of a user goal, never inside a list, a dict, or another compound
5814
+ %% passed as data. See the note on expand_term_interp/3.
5815
+ expand_interpolations((Head :- Body), Bindings, (Head :- ExpandedBody)) :-
5816
+ !,
5817
+ expand_body_interp(Body, Bindings, ExpandedBody).
4940
5818
  expand_interpolations(Term, Bindings, ExpandedTerm) :-
4941
- expand_term_interp(Term, Bindings, ExpandedTerm).
5819
+ expand_body_interp(Term, Bindings, ExpandedTerm).
4942
5820
 
4943
5821
  %% expand_term_interp(+Term, +Bindings, -Expanded)
4944
5822
  %% Main term expansion predicate
@@ -4947,13 +5825,16 @@ expand_interpolations(Term, Bindings, ExpandedTerm) :-
4947
5825
  expand_term_interp(Var, _, Var) :-
4948
5826
  var(Var), !.
4949
5827
 
4950
- % Strings - check if they need interpolation
4951
- expand_term_interp(String, Bindings, Expanded) :-
4952
- string(String), !,
4953
- ( string_needs_interpolation(String)
4954
- -> build_format_goal(String, Bindings, Expanded)
4955
- ; Expanded = String
4956
- ).
5828
+ % Strings are LITERAL here. Interpolation is decided by expand_body_interp/3, which
5829
+ % only rewrites a string that is a *direct argument of a goal*. Rewriting every
5830
+ % string, nested ones included, corrupted data: the shell command
5831
+ % exec(bash(argv: [..., "-w", "%{http_code}", Url]), R)
5832
+ % had its `%{http_code}` element replaced by a format goal inside the argv list, so
5833
+ % the list arrived truncated at `-w`. A JSON document, a regex, an `awk` program and
5834
+ % a `jq` filter all contain braces and must survive verbatim.
5835
+ expand_term_interp(String, _, String) :-
5836
+ string(String),
5837
+ !.
4957
5838
 
4958
5839
  % Atoms - check if they need interpolation (for atom strings)
4959
5840
  expand_term_interp(Atom, Bindings, Expanded) :-
@@ -4999,6 +5880,19 @@ expand_term_interp(Term, Bindings, ExpandedTerm) :-
4999
5880
  expand_body_interp(Var, _, Var) :-
5000
5881
  var(Var), !.
5001
5882
 
5883
+ % A list or a dict is DATA, not a sequence of goals. Descending into one treats its
5884
+ % elements as goal arguments and hoists any `{...}` string it contains, which
5885
+ % rewrote data into a goal term:
5886
+ % argv: ["curl","-w","%{http_code}","u"] became
5887
+ % argv: [curl,-w|((param(http_code,_),format(...)),[Temp,u])]
5888
+ % so `exec(bash(argv: [...]))` reached the shell with a truncated argument list.
5889
+ expand_body_interp(Data, _, Data) :-
5890
+ ( Data == []
5891
+ ; Data = [_|_]
5892
+ ; is_dict(Data)
5893
+ ),
5894
+ !.
5895
+
5002
5896
  % Conjunction
5003
5897
  expand_body_interp((A, B), Bindings, ExpandedConj) :- !,
5004
5898
  expand_body_interp(A, Bindings, EA),
@@ -5047,6 +5941,7 @@ expand_body_interp(Goal, Bindings, ExpandedGoal) :-
5047
5941
  expand_body_interp(Goal, Bindings, ExpandedGoal) :-
5048
5942
  compound(Goal),
5049
5943
  Goal =.. [Functor|Args],
5944
+ interp_arg_predicate(Functor),
5050
5945
  expand_args_hoist(Args, Bindings, PreGoals, ExpandedArgs, true),
5051
5946
  !,
5052
5947
  Bare =.. [Functor|ExpandedArgs],
@@ -5062,6 +5957,21 @@ expand_body_interp(Goal, Bindings, ExpandedGoal) :-
5062
5957
  % Atoms/other
5063
5958
  expand_body_interp(Goal, _, Goal).
5064
5959
 
5960
+ %% Predicates whose string arguments interpolate `{Var}`. Interpolation lives in
5961
+ %% the *description* of an LLM step and in the question of a judgment; anywhere else
5962
+ %% a string is data and must survive verbatim. Hoisting for every goal made a string
5963
+ %% of data into a goal argument: `X = "%{code}"` became an interpolation of the
5964
+ %% variable `code`, and a shell argv containing `%{http_code}` was rewritten.
5965
+ %%
5966
+ %% (task/N, prompt/N, answer/1, system/1, user/1, output/1, log/1 and yield/1 are
5967
+ %% handled by goal_with_string_arg/4 above, for their first argument.)
5968
+ interp_arg_predicate(judge).
5969
+ interp_arg_predicate(choose).
5970
+ interp_arg_predicate(rate).
5971
+ interp_arg_predicate(verify).
5972
+ interp_arg_predicate(probability).
5973
+ interp_arg_predicate(holds).
5974
+
5065
5975
  %% goal_with_string_arg(+Goal, -Functor, -StringArg, -RestArgs)
5066
5976
  %% Match goals that take a string as their first argument
5067
5977
  goal_with_string_arg(answer(S), answer, S, []) :- string(S).