scout-ai 1.2.3 → 2.0.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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +138 -50
  3. data/README.md +171 -290
  4. data/Rakefile +17 -1
  5. data/VERSION +1 -1
  6. data/doc/Improvements.md +325 -0
  7. data/doc/StartHere.md +110 -0
  8. data/doc/developer/Architecture.md +126 -0
  9. data/doc/developer/Backends.md +199 -0
  10. data/doc/developer/ChatLifecycle.md +183 -0
  11. data/doc/developer/DelegationInternals.md +295 -0
  12. data/doc/developer/DesignPrinciples.md +245 -0
  13. data/doc/developer/PromptProcessing.md +292 -0
  14. data/doc/developer/Provenance.md +317 -0
  15. data/doc/user/BuildingAgents.md +345 -0
  16. data/doc/user/Cookbook.md +333 -0
  17. data/doc/user/CoreConcepts.md +181 -0
  18. data/doc/user/Delegation.md +191 -0
  19. data/doc/user/GettingStarted.md +159 -0
  20. data/doc/user/ManagingContext.md +163 -0
  21. data/doc/user/MultiAgentWorkflows.md +256 -0
  22. data/doc/user/Python.md +159 -0
  23. data/doc/user/RunningInference.md +200 -0
  24. data/doc/user/ToolCalling.md +193 -0
  25. data/doc/user/WritingChats.md +197 -0
  26. data/lib/scout/llm/agent/chat.rb +61 -11
  27. data/lib/scout/llm/agent/delegate.rb +274 -65
  28. data/lib/scout/llm/agent/iterate.rb +2 -2
  29. data/lib/scout/llm/agent/save.rb +273 -0
  30. data/lib/scout/llm/agent/workflow.rb +164 -0
  31. data/lib/scout/llm/agent.rb +86 -61
  32. data/lib/scout/llm/ask.rb +62 -17
  33. data/lib/scout/llm/backends/anthropic.rb +9 -2
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +183 -99
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +196 -26
  38. data/lib/scout/llm/backends/ollama.rb +13 -1
  39. data/lib/scout/llm/backends/openai.rb +0 -2
  40. data/lib/scout/llm/backends/openwebui.rb +20 -13
  41. data/lib/scout/llm/backends/relay.rb +22 -22
  42. data/lib/scout/llm/backends/responses.rb +1 -1
  43. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  44. data/lib/scout/llm/chat/annotation.rb +39 -10
  45. data/lib/scout/llm/chat/parse.rb +28 -6
  46. data/lib/scout/llm/chat/persist.rb +25 -0
  47. data/lib/scout/llm/chat/process/clear.rb +41 -6
  48. data/lib/scout/llm/chat/process/files.rb +21 -6
  49. data/lib/scout/llm/chat/process/meta.rb +421 -34
  50. data/lib/scout/llm/chat/process/options.rb +21 -1
  51. data/lib/scout/llm/chat/process/tools.rb +56 -15
  52. data/lib/scout/llm/chat/process.rb +4 -0
  53. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  54. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  55. data/lib/scout/llm/chat/prompt.rb +48 -0
  56. data/lib/scout/llm/chat/provenance.rb +775 -0
  57. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  58. data/lib/scout/llm/chat.rb +18 -2
  59. data/lib/scout/llm/embed.rb +11 -3
  60. data/lib/scout/llm/image.rb +86 -0
  61. data/lib/scout/llm/mcp.rb +10 -2
  62. data/lib/scout/llm/rag.rb +3 -3
  63. data/lib/scout/llm/tools/call.rb +160 -11
  64. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  65. data/lib/scout/llm/tools/workflow.rb +32 -16
  66. data/lib/scout/model/python/huggingface/causal.rb +23 -5
  67. data/lib/scout/model/python/huggingface.rb +2 -1
  68. data/lib/scout-ai.rb +1 -0
  69. data/python/README.md +197 -14
  70. data/python/scout_ai/huggingface/eval.py +245 -34
  71. data/python/tests/test_huggingface_eval.py +58 -0
  72. data/research/ChatAnalyst-required-changes.md +167 -0
  73. data/research/agent-delegation-analysis.md +810 -0
  74. data/research/agent-meta-provenance-integration-plan.md +622 -0
  75. data/research/agent-workflow-analysis.md +1120 -0
  76. data/research/backends-analysis.md +836 -0
  77. data/research/chat-core-analysis.md +946 -0
  78. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  79. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  80. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  81. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  82. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  83. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  84. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  85. data/research/chatanalyst-provenance/final-report.md +45 -0
  86. data/research/chatanalyst-provenance/resumption.md +37 -0
  87. data/research/coding-philosophy-analysis.md +928 -0
  88. data/research/commands-analysis.md +947 -0
  89. data/research/multi-agent-patterns-analysis.md +853 -0
  90. data/research/prompt-strategies-analysis.md +630 -0
  91. data/research/prov-verbosity-fix-notes.md +77 -0
  92. data/research/provenance-analysis.md +469 -0
  93. data/research/provenance-navigation-design.md +640 -0
  94. data/research/synthesis-report.md +487 -0
  95. data/research/tools-system-analysis.md +779 -0
  96. data/scout-ai.gemspec +100 -11
  97. data/scout_commands/agent/ask +13 -3
  98. data/scout_commands/agent/kb +2 -0
  99. data/scout_commands/llm/ask +11 -4
  100. data/scout_commands/llm/md +76 -0
  101. data/scout_commands/llm/process_queries +48 -0
  102. data/scout_commands/llm/prov +602 -0
  103. data/scout_commands/llm/word +71 -0
  104. data/scout_commands/workflow/mcp +43 -0
  105. data/share/word/reference.docx +0 -0
  106. data/test/etc/AI/mock.yaml +11 -0
  107. data/test/fixtures/backends/anthropic.json +19 -0
  108. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  109. data/test/fixtures/backends/bedrock.json +8 -0
  110. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  111. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  112. data/test/fixtures/backends/ollama.json +16 -0
  113. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  114. data/test/fixtures/backends/openai_chat.json +21 -0
  115. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  116. data/test/fixtures/backends/responses.json +33 -0
  117. data/test/fixtures/backends/responses_tool_call.json +28 -0
  118. data/test/integration/README.md +32 -0
  119. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  120. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  121. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  122. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  123. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  124. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  125. data/test/integration/scout/model/test_base.rb +91 -0
  126. data/test/scout/llm/agent/test_chat.rb +8 -2
  127. data/test/scout/llm/agent/test_save.rb +413 -0
  128. data/test/scout/llm/agent/test_workflow.rb +110 -0
  129. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  130. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  131. data/test/scout/llm/backends/test_huggingface.rb +137 -42
  132. data/test/scout/llm/backends/test_ollama.rb +70 -20
  133. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  134. data/test/scout/llm/backends/test_relay.rb +4 -2
  135. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  136. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  137. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  138. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  139. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  140. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  141. data/test/scout/llm/chat/test_parse.rb +70 -15
  142. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  143. data/test/scout/llm/chat/test_provenance.rb +240 -0
  144. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  145. data/test/scout/llm/test_agent.rb +13 -36
  146. data/test/scout/llm/test_ask.rb +75 -52
  147. data/test/scout/llm/test_chat.rb +107 -13
  148. data/test/scout/llm/test_embed.rb +48 -0
  149. data/test/scout/llm/test_rag.rb +23 -16
  150. data/test/scout/llm/test_tools.rb +12 -1
  151. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  152. data/test/scout/llm/tools/test_mcp.rb +5 -3
  153. data/test/scout/llm/tools/test_workflow.rb +23 -2
  154. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  155. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  156. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  157. data/test/scout/model/python/test_torch.rb +2 -0
  158. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  159. data/test/scout/model/test_base.rb +4 -2
  160. data/test/support/availability.rb +231 -0
  161. data/test/support/fake_clients.rb +138 -0
  162. data/test/support/fixtures.rb +21 -0
  163. data/test/support/infrastructure_probes.rb +136 -0
  164. data/test/support/mock_backend.rb +215 -0
  165. data/test/test_helper.rb +32 -2
  166. metadata +99 -10
  167. data/doc/Agent.md +0 -327
  168. data/doc/Chat.md +0 -458
  169. data/doc/LLM.md +0 -340
  170. data/doc/RAG.md +0 -129
  171. data/scout_commands/documenter +0 -148
  172. data/test/scout/llm/backends/test_openai.rb +0 -192
  173. data/test/scout/llm/backends/test_responses.rb +0 -238
  174. data/test/scout/llm/test_parse.rb +0 -98
@@ -0,0 +1,1120 @@
1
+ > **Disclaimer:** This is an architectural investigation, not normative
2
+ > documentation. It was produced during a documentation-revamp effort and may
3
+ > be outdated relative to the current codebase. Treat it as supporting
4
+ > reference material. For maintained documentation, see
5
+ > [../../doc/](../../doc/).
6
+ >
7
+
8
+
9
+ # 04 — AgentWorkflow: The Agent-as-Workflow Bridge
10
+
11
+ > **Primary source file:** `lib/scout/llm/agent/workflow.rb` (170 lines)
12
+ > **Related source files:**
13
+ > - `lib/scout/llm/agent.rb` (constructor, `ask`, `workflow` method, `load_agent`)
14
+ > - `lib/scout/llm/agent/chat.rb` (`start_chat`, `current_chat`, `chat`)
15
+ > - `lib/scout/llm/agent/iterate.rb` (`iterate`, `iterate_dictionary`)
16
+ > - `lib/scout/llm/chat/persist.rb` (`.chat` extension registration, save/load drivers)
17
+ > - `lib/scout/llm/chat/process/meta.rb` (`Chat.project`, `Chat.serialize_meta`, `jobs`)
18
+ > - `lib/scout/llm/chat/process/tools.rb` (`Chat.allow_dir`, `Chat.allow_read_dir`)
19
+ > - `lib/scout/llm/chat/prompt.rb` (`full_tool_calls`, `max_tool_outputs`)
20
+ > - `lib/scout-gear/.../workflow/definition.rb` (`include_workflow`, `TYPE_EXTENSIONS`)
21
+ > - `lib/scout-gear/.../workflow/step/file.rb` (`files_dir`, `file`)
22
+ > - SC26 workflows: `Planned`, `Critic`, `Branched`, `Refined`, `InterpretData`
23
+
24
+ ---
25
+
26
+ ## 1. What AgentWorkflow Is
27
+
28
+ `AgentWorkflow` is a Scout **Workflow module** that provides a compact DSL for
29
+ declaring **chat tasks** — Scout workflow tasks whose inputs, outputs, and
30
+ persistence are all built around the `Chat` abstraction. When a Workflow module
31
+ writes `self.include_workflow AgentWorkflow`, it gains:
32
+
33
+ | Helper / construct | Kind | Purpose |
34
+ |------------------------|-----------------|-----------------------------------------------------------|
35
+ | `chat_task` | DSL declaration | Define a workflow task of type `:chat` with a chat input |
36
+ | `chat` | Instance helper | Parse or build the task's `Chat` from the `chat` input |
37
+ | `options` | Instance helper | Extract `LLM.options` from the current chat |
38
+ | `agent` | Instance helper | Factory: load an `LLM::Agent`, wire it to this job |
39
+ | `agent_options` | Instance helper | Strip private keys from an options hash |
40
+ | `tooling` | Instance helper | Extract tool/kb/mcp/introduce messages from the chat |
41
+ | `tooling_intro` | Instance helper | Subset of `tooling` containing only `introduce` messages |
42
+ | `log_agent` | Instance helper | Persist an agent's chats into the job's `log/` directory |
43
+ | `add_chat_dependencies`| Instance helper | Register chat-referenced jobs as Scout dependencies |
44
+
45
+ It also patches `Workflow.require_workflow` and `LLM::Agent` (see §10).
46
+
47
+ ---
48
+
49
+ ## 2. Module Structure and `include_workflow`
50
+
51
+ ### 2.1 Definition
52
+
53
+ ```ruby
54
+ module AgentWorkflow
55
+ extend Workflow # ← it is itself a Workflow module
56
+
57
+ helper :chat do |chat=nil| ... end
58
+ helper :options do ... end
59
+ helper :agent do |name=nil, chat:nil, options:nil, tooling:nil, files:nil, **kwargs| ... end
60
+ helper :log_agent do |agent, agent_name=nil| ... end
61
+ # ... other helpers
62
+
63
+ # Plus the chat_task definition patched into Workflow (see §3)
64
+ end
65
+ ```
66
+
67
+ ### 2.2 How `include_workflow` extends a host module
68
+
69
+ `Workflow#include_workflow` (defined in `scout-gear/lib/scout/workflow/definition.rb`,
70
+ line 233) performs a **five-step merge**:
71
+
72
+ ```ruby
73
+ def include_workflow(workflow)
74
+ workflow.documentation
75
+ self.asynchronous_exports += workflow.asynchronous_exports
76
+ self.synchronous_exports += workflow.synchronous_exports
77
+ self.exec_exports += workflow.exec_exports
78
+ self.stream_exports += workflow.stream_exports
79
+ self.tasks.merge! workflow.tasks # ← merge task definitions
80
+ self.tasks.each{|_,t| t.workflow = workflow }
81
+ self.helpers.merge! workflow.helpers # ← merge helper definitions
82
+ self.include workflow # ← Ruby module inclusion
83
+ end
84
+ ```
85
+
86
+ **Effect:** all helpers defined inside `AgentWorkflow` become callable inside
87
+ the host module's task blocks (`self.agent`, `self.chat`, etc.). The host does
88
+ not need to re-define any of them.
89
+
90
+ ### 2.3 Canonical usage pattern
91
+
92
+ Every SC26 pipeline workflow follows the same two-line preamble:
93
+
94
+ ```ruby
95
+ module Planned
96
+ extend Workflow
97
+ self.include_workflow AgentWorkflow # ← gains all helpers + chat_task
98
+ # ...
99
+ end
100
+ ```
101
+
102
+ ---
103
+
104
+ ## 3. `chat_task` — The Core DSL Construct
105
+
106
+ ### 3.1 Declaration syntax
107
+
108
+ ```ruby
109
+ chat_task :task_name do
110
+ # ... arbitrary Ruby that builds and returns an Agent or Hash
111
+ end
112
+ ```
113
+
114
+ This is defined not inside the `AgentWorkflow` module body but as a **patch on
115
+ `Module`** (via `class << self` inside the `Workflow` module reopening):
116
+
117
+ ```ruby
118
+ module Workflow
119
+ def chat_task(task_name, &block)
120
+ input :chat, :text, 'Chat in Scout-AI chat-file format'
121
+ task task_name => :chat do |chat|
122
+ # ... execution body (see §3.3)
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ Because `Workflow` is reopened at the class level, `chat_task` is available to
129
+ **every** Workflow module that has `require`'d the agent library, not just those
130
+ that explicitly `include_workflow AgentWorkflow`. However, to use `self.agent`
131
+ and the other helpers inside the block, `include_workflow AgentWorkflow` (or
132
+ equivalent) is required.
133
+
134
+ ### 3.2 Differences from a regular `task`
135
+
136
+ | Aspect | `task :name => :type do ... end` | `chat_task :name do ... end` |
137
+ |------------------------|---------------------------------------|--------------------------------------------------|
138
+ | **Input** | Manually declared with `input` | Automatically declares `input :chat, :text, ...` |
139
+ | **Type** | Any registered type (`:string`, `:tsv`, etc.) | Always `:chat` |
140
+ | **File extension** | Determined by `TYPE_EXTENSIONS[type]` | Always `.chat` (registered by `persist.rb`) |
141
+ | **Return processing** | Raw return value is the result | `LLM::Agent` → auto-processed via `agent.chat` or differential; `Hash` → wrapped; error → JSON error message |
142
+ | **Provenance** | Manual | `Chat.project(self.short_path, result)` called automatically |
143
+ | **Agent logging** | Manual | `log_agent(agent)` called automatically when the block returns an Agent |
144
+ | **Error handling** | Propagates exceptions | `ScoutException` → graceful error message in chat format |
145
+
146
+ ### 3.3 The full execution body (annotated)
147
+
148
+ ```ruby
149
+ task task_name => :chat do |chat|
150
+ begin
151
+ # 1. Execute the user's block. The block can return:
152
+ # - an LLM::Agent (most common)
153
+ # - a Hash (single message)
154
+ # - an Array (raw message list)
155
+ # - anything else (returned as-is)
156
+ response = self.instance_exec(&block)
157
+
158
+ # 2. Normalize the response into a message array
159
+ result = if LLM::Agent === response
160
+ agent = response
161
+ # If the last message is from the user, the model hasn't responded yet.
162
+ # Run the agent's chat to get the assistant's response.
163
+ if agent.current_chat.last[:role].to_s == 'user'
164
+ agent.chat(return_messages: true)
165
+ else
166
+ # Already responded; extract only the messages after start_chat
167
+ agent.current_chat - agent.start_chat
168
+ end.tap { log_agent(agent) } # ← persist agent chats to log/
169
+
170
+ elsif Hash === response
171
+ [response] # single message
172
+
173
+ else
174
+ response # pass through (Array, etc.)
175
+ end
176
+
177
+ # 3. Tag the result with a provenance meta-message and return
178
+ Chat.project(self.short_path, result)
179
+
180
+ rescue ScoutException
181
+ # 4. On expected failure, produce an error message in chat format
182
+ error = { role: :assistant,
183
+ content: { exception: $!, job: self.short_path }.to_json }
184
+ Chat.project(self.short_path, [error])
185
+ end
186
+ end
187
+ ```
188
+
189
+ ### 3.4 Key design decisions
190
+
191
+ 1. **Block return value determines behavior.** Returning an `Agent` triggers the
192
+ full lifecycle (auto-run, log, project). Returning raw data is also supported.
193
+
194
+ 2. **Lazy execution.** If the block sets up an agent but the last message is
195
+ still `:user`, `chat_task` calls `agent.chat(return_messages: true)` to
196
+ trigger inference. If the block already ran the agent (e.g., `agent.json`),
197
+ the last message is `:assistant` and the differential is returned directly.
198
+
199
+ 3. **Automatic provenance.** Every result is wrapped with `Chat.project`, which
200
+ prepends a `{:role => :meta, :content => "job=<short_path>"}` marker.
201
+
202
+ 4. **Graceful degradation.** `ScoutException` (the agent-level exception class)
203
+ is caught and converted to a JSON error message, not propagated as a crash.
204
+
205
+ ---
206
+
207
+ ## 4. The `chat` Helper
208
+
209
+ ```ruby
210
+ helper :chat do |chat=nil|
211
+ @chat ||= begin
212
+ chat = recursive_inputs[:chat] # pull from recursive input chain
213
+ chat = Chat.parse(chat) if String === chat
214
+ Chat.setup(chat)
215
+ chat
216
+ end
217
+ end
218
+ ```
219
+
220
+ **Behavior:**
221
+ - Memoized (`@chat ||= ...`): the chat is parsed once and reused.
222
+ - Pulls the raw `chat` input from `recursive_inputs`, which is Scout's mechanism
223
+ for passing inputs down a dependency chain. In `chat_task`, this is the `chat`
224
+ parameter value.
225
+ - If the value is a String, it is parsed via `Chat.parse` (YAML/markdown →
226
+ message array). If already a Chat (or Array), it is set up in-place.
227
+ - Returns a `Chat` instance (an Array annotated with the `Chat` module).
228
+
229
+ **Usage in task blocks:**
230
+ ```ruby
231
+ chat_task :ask do
232
+ chat = self.chat # ← the parsed input chat
233
+ chat.follow step(:request).load # ← append dependency results
234
+ # ...
235
+ end
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 5. The `options` Helper
241
+
242
+ ```ruby
243
+ helper :options do
244
+ @options ||= LLM.options self.chat
245
+ end
246
+ ```
247
+
248
+ Extracts LLM configuration options (endpoint, model, parameters, etc.) from the
249
+ current chat. `LLM.options` reads these from special `:option` role messages
250
+ embedded in the chat (e.g., `{:role => :option, :content => "model gpt-4o"}`).
251
+
252
+ These options are typically passed to the `agent` helper to configure the
253
+ loaded agent.
254
+
255
+ ---
256
+
257
+ ## 6. The `agent` Helper — The Factory Method
258
+
259
+ This is the most important helper. It loads (or retrieves a cached) `LLM::Agent`,
260
+ configures it with tooling, chat context, system messages, and files, and
261
+ returns it ready for use.
262
+
263
+ ### 6.1 Signature
264
+
265
+ ```ruby
266
+ helper :agent do |name = nil, chat: nil, options: nil, tooling: nil,
267
+ files: nil, **kwargs|
268
+ ```
269
+
270
+ | Parameter | Type | Default | Description |
271
+ |-------------|-----------------|----------------|---------------------------------------------------|
272
+ | `name` | String / Symbol / `nil` | `nil` | Agent name to load via `LLM.load_agent`. `nil` creates a bare agent. |
273
+ | `chat` | Chat / Array / `nil` | `nil` | Conversation context to follow into the agent's start_chat |
274
+ | `options` | Hash / `nil` | `self.options` | LLM options (model, endpoint, parameters, etc.) |
275
+ | `tooling` | Array / `nil` | `self.tooling` | Tool/introduce/kb/mcp messages to prepend |
276
+ | `files` | Array / `nil` | `nil` | File paths to attach to the agent's chat |
277
+ | `**kwargs` | varies | — | Merged into options via `IndiferentHash.add_defaults` |
278
+
279
+ ### 6.2 What it does, step by step
280
+
281
+ ```ruby
282
+ # Step 1: Resolve defaults
283
+ options = self.options if options.nil? # inherit task's LLM options
284
+ tooling = self.tooling if tooling.nil? # inherit task's tooling
285
+ options = IndiferentHash.add_defaults options, kwargs # merge keyword args
286
+
287
+ # Step 2: Load the agent
288
+ agent = LLM.load_agent name, agent_options(options)
289
+ agent.job = self # link agent to this workflow step
290
+
291
+ # Step 3: Inject tooling
292
+ agent.start_chat.follow tooling if tooling && !tooling.empty?
293
+
294
+ # Step 4: System messages about job context
295
+ agent.start_chat.system "Your current working directory is #{Dir.pwd}."
296
+ agent.start_chat.system "You are working through an ask job with path #{self.path}..."
297
+ agent.start_chat.system "Tool call content may be truncated after #{Chat.full_tool_calls}, ..."
298
+ # (these let the agent know about its sandbox boundaries)
299
+
300
+ # Step 5: Dependency context (if the workflow has deps)
301
+ if dependencies.any?
302
+ agent.start_chat.system "This workflow job has the following dependencies:\n#{rec_dependencies...}"
303
+ end
304
+
305
+ # Step 6: Cross-job awareness
306
+ if chat && !chat.empty?
307
+ other_jobs = LLM.chat(chat.dup).jobs
308
+ if other_jobs.any?
309
+ agent.start_chat.system "There are other jobs found in this chat:\n#{other_jobs...}"
310
+ end
311
+ end
312
+
313
+ # Step 7: File attachments
314
+ files.each do |path|
315
+ target = file(path)
316
+ if File.exist?(target.find)
317
+ agent.start_chat.file target
318
+ elsif File.exist?(path.find)
319
+ agent.start_chat.file path
320
+ end
321
+ end if files
322
+
323
+ # Step 8: Inject conversation context (with filtering)
324
+ if chat && !chat.empty?
325
+ chat = LLM.chat(chat)
326
+ # Remove stale assignment/context messages from previous pipeline steps
327
+ chat.reject! { |msg| msg[:content].to_s.start_with? 'You have been assigned' }
328
+ chat.reject! { |msg| msg[:content].to_s.start_with? 'This workflow job has the following' }
329
+ chat.reject! { |msg| msg[:content].to_s.start_with? 'There are other jobs found in this chat' }
330
+ agent.start_chat.follow chat
331
+ end
332
+
333
+ agent
334
+ ```
335
+
336
+ ### 6.3 What it returns
337
+
338
+ An `LLM::Agent` instance with:
339
+ - `agent.job` set to the current `Step` (for provenance tracking)
340
+ - `agent.start_chat` populated with tooling, system messages, and context
341
+ - Ready for `.user(...)` / `.chat(...)` / `.json(...)` calls
342
+
343
+ ### 6.4 The `agent_options` sub-helper
344
+
345
+ ```ruby
346
+ helper :agent_options do |options|
347
+ IndiferentHash.setup options.except(:agent, 'agent', :chat, 'chat')
348
+ end
349
+ ```
350
+
351
+ Strips `:agent` and `:chat` keys from the options hash before passing to
352
+ `LLM.load_agent`. This prevents infinite recursion (load_agent would try to
353
+ re-parse the chat input).
354
+
355
+ ### 6.5 Context filtering — a subtle but important detail
356
+
357
+ When a chat is followed into the agent's start_chat, three categories of
358
+ messages are **stripped**:
359
+
360
+ 1. `"You have been assigned"` — assignment prompts from prior pipeline steps
361
+ 2. `"This workflow job has the following"` — dependency listings from prior steps
362
+ 3. `"There are other jobs found in this chat"` — cross-job awareness messages
363
+
364
+ This prevents context bloat as chats flow through multi-step pipelines.
365
+
366
+ ---
367
+
368
+ ## 7. The `tooling` and `tooling_intro` Helpers
369
+
370
+ ### 7.1 `tooling`
371
+
372
+ ```ruby
373
+ helper :tooling do
374
+ @tooling ||= begin
375
+ chat = self.chat
376
+ chat.remove_role(:tool) +
377
+ chat.remove_role(:kb) +
378
+ chat.remove_role(:mcp) +
379
+ chat.remove_role(:introduce)
380
+ end
381
+ end
382
+ ```
383
+
384
+ **What it does:** Extracts all configuration messages from the input chat that
385
+ define the agent's capabilities:
386
+ - `:tool` — tool definitions (JSON schemas)
387
+ - `:kb` — knowledge base definitions
388
+ - `:mcp` — Model Context Protocol server definitions
389
+ - `:introduce` — agent introduction/instruction messages
390
+
391
+ These are **removed** from the chat (mutating it) and **returned** as a combined
392
+ array. The memoization (`@tooling ||=`) means the extraction happens once.
393
+
394
+ **Effect:** The tooling messages are extracted so they can be selectively
395
+ re-injected into agents via the `agent` helper's `tooling:` parameter. This
396
+ allows different agents in the same pipeline to receive different subsets of
397
+ tools.
398
+
399
+ ### 7.2 `tooling_intro`
400
+
401
+ ```ruby
402
+ helper :tooling_intro do
403
+ self.tooling.select { |msg| msg[:role] == 'introduce ' }
404
+ end
405
+ ```
406
+
407
+ **Note the trailing space in `'introduce '`** — this is present in the source
408
+ code. This selects only the `introduce` role messages, which contain agent
409
+ instruction/context prose. It is used when an agent should receive the
410
+ introduction context but not the actual tool definitions (e.g., a Planner agent
411
+ that should understand the available tools conceptually but not call them).
412
+
413
+ **Typical usage in SC26:**
414
+ ```ruby
415
+ # Planner gets intros only (no actual tools to call)
416
+ agent = self.agent :Planner, chat: chat, tooling: self.tooling_intro
417
+
418
+ # Worker gets full tooling (tools + intros)
419
+ agent = self.agent worker_agent, chat: chat, tooling: self.tooling
420
+
421
+ # Final report agent gets no tools at all
422
+ agent = self.agent :User, tooling: false, chat: chat
423
+ ```
424
+
425
+ ---
426
+
427
+ ## 8. The `log_agent` Helper — Provenance Persistence
428
+
429
+ ### 8.1 Signature and behavior
430
+
431
+ ```ruby
432
+ helper :log_agent do |agent, agent_name=nil|
433
+ dir = agent_name ? file('log')[agent_name] : file('log')
434
+
435
+ agent.chats.each do |name, other|
436
+ dir.society[name].set_extension('chat').write other.current_chat.print
437
+ #add_chat_dependencies(other.current_chat) # ← commented out
438
+ end if agent.chats
439
+
440
+ dir['agent.chat'].write agent.current_chat.print
441
+ #add_chat_dependencies(agent.current_chat) # ← commented out
442
+
443
+ update_info :dependencies, dependencies.collect { |dependency| dependency.path.find }
444
+ agent
445
+ end
446
+ ```
447
+
448
+ ### 8.2 What it persists
449
+
450
+ | File written | Content |
451
+ |--------------------------------------|---------------------------------------------------|
452
+ | `<files_dir>/log/agent.chat` | The agent's full current_chat (all messages) |
453
+ | `<files_dir>/log/<agent_name>/agent.chat` | Same, but under a named subdirectory |
454
+ | `<files_dir>/log/<agent_name>/society/<delegated_name>.chat` | For each agent in the society hash, its current chat |
455
+
456
+ ### 8.3 Provenance trail
457
+
458
+ These persisted `.chat` files are the **evidence trail** for what an agent did.
459
+ They are discoverable by:
460
+ - `Chat.job_agent_chat_files(job)` — globs `job.file('log').glob('**/*.chat')`
461
+ - The `ChatAnalyst` agent's `Session.discover_job` method
462
+ - The `scout llm prov` command
463
+
464
+ ### 8.4 `add_chat_dependencies` (currently commented out)
465
+
466
+ ```ruby
467
+ helper :add_chat_dependencies do |chat|
468
+ chat.jobs.each do |job_path|
469
+ next if job_path.to_s == self.short_path.to_s
470
+ next if dependencies.find{|dep| dep.path.find == job_path.find }
471
+ begin
472
+ job = Step.load(job_path)
473
+ dependencies << job unless dependencies.select{|dep| dep.path}.include?(job)
474
+ rescue
475
+ end
476
+ end
477
+ end
478
+ ```
479
+
480
+ This would register any jobs referenced in the chat's meta messages as Scout
481
+ dependencies of the current step. The calls are commented out in the current
482
+ source, meaning cross-job dependency links are **not** automatically
483
+ established at present. The `update_info` call at the end of `log_agent` still
484
+ writes the dependency list to the step's info file.
485
+
486
+ ### 8.5 Auto-invocation
487
+
488
+ `log_agent` is called **automatically** by `chat_task` when the block returns an
489
+ `LLM::Agent` (see §3.3, the `.tap { log_agent(agent) }` call). You rarely call
490
+ it manually — unless you are managing multiple agents in a single task (as the
491
+ `Branched` workflow does).
492
+
493
+ ---
494
+
495
+ ## 9. The `.chat` Extension and Persistence
496
+
497
+ ### 9.1 Registration
498
+
499
+ `lib/scout/llm/chat/persist.rb` registers the `.chat` extension:
500
+
501
+ ```ruby
502
+ Workflow::TYPE_EXTENSIONS[:chat] = :chat
503
+
504
+ Persist.save_drivers[:chat] = proc do |file, content|
505
+ case content
506
+ when LLM::Agent
507
+ new_chat = content.current_chat - content.start_chat
508
+ Open.sensible_write(file, LLM.print(new_chat))
509
+ when Array
510
+ Open.sensible_write(file, LLM.print(content))
511
+ else
512
+ # ... stream handling
513
+ end
514
+ end
515
+
516
+ Persist.load_drivers[:chat] = proc do |file|
517
+ String === file ? LLM.chat(file) : file
518
+ end
519
+ ```
520
+
521
+ ### 9.2 How chat task results are saved and loaded
522
+
523
+ 1. **On save** (`Persist.save_drivers[:chat]`):
524
+ - If the result is an `LLM::Agent`: saves `current_chat - start_chat` (only the
525
+ new messages, not the preamble).
526
+ - If the result is an Array: saves all messages.
527
+ - Uses `Open.sensible_write` (atomic write with temp file + rename).
528
+ - Serialization format is `LLM.print(chat)` — human-readable chat markup.
529
+
530
+ 2. **On load** (`Persist.load_drivers[:chat]`):
531
+ - Reads the file content as a string and parses it with `LLM.chat(string)`.
532
+ - Returns a `Chat` instance.
533
+
534
+ 3. **File location**: `<step.path>` (which already has no extension for chat
535
+ types, since the extension is `:chat` and `TYPE_EXTENSIONS[:chat] = :chat`).
536
+ The job result file IS the `.chat` file.
537
+
538
+ 4. **Loading a dependency's chat**: `step(:dep_name).load` returns the parsed
539
+ Chat object, which can then be `follow`ed into the current chat.
540
+
541
+ ### 9.3 The `Chat.project` function
542
+
543
+ ```ruby
544
+ def self.project(job, messages)
545
+ projected = Array(messages).reject { |message| message[:role].to_s == 'meta' }.collect(&:dup)
546
+ return [] if projected.empty?
547
+ [{ role: :meta, content: serialize_meta(job: job.to_s) }] + projected
548
+ end
549
+ ```
550
+
551
+ **Purpose:** When a chat task completes, its result messages are "projected"
552
+ from the job. The projection:
553
+ 1. Strips any existing `:meta` messages (to avoid accumulation).
554
+ 2. Prepends a single `:meta` marker with `job=<short_path>`.
555
+ 3. Returns the tagged message array.
556
+
557
+ This meta marker is how downstream tasks and the `Chat.jobs` method discover
558
+ which job produced a given chat segment.
559
+
560
+ ### 9.4 The `Chat.jobs` method
561
+
562
+ ```ruby
563
+ def job_paths
564
+ role_messages(:meta).collect do |message|
565
+ Path.setup(Chat.parse_meta(message[:content])[:job])
566
+ end.compact.uniq
567
+ end
568
+ alias jobs job_paths
569
+ ```
570
+
571
+ Scans the chat for `:meta` role messages, parses each one to extract the `job=`
572
+ key, and returns the list of job paths. This is how the system builds the
573
+ provenance graph: a chat that follows another job's output inherits its meta
574
+ markers, and `chat.jobs` reveals all contributing jobs.
575
+
576
+ ---
577
+
578
+ ## 10. Patches to Existing Classes
579
+
580
+ ### 10.1 `Workflow.require_workflow` — agent-aware fallback
581
+
582
+ ```ruby
583
+ class << self
584
+ alias require_workflow_old require_workflow
585
+ end
586
+
587
+ def self.require_workflow(name, ...)
588
+ begin
589
+ require_workflow_old(name, ...)
590
+ rescue => e
591
+ begin
592
+ LLM.load_agent(name).workflow # ← if not a regular workflow, try loading as agent
593
+ rescue
594
+ raise e
595
+ end
596
+ end
597
+ end
598
+ ```
599
+
600
+ When `Workflow.require_workflow("SomeAgent")` fails because no standard workflow
601
+ file exists, it falls back to loading the name as an agent and extracting its
602
+ `.workflow`. This is how agent-based workflows like `Planned` are resolved.
603
+
604
+ ### 10.2 `LLM::Agent#job=` — provenance link
605
+
606
+ ```ruby
607
+ class LLM::Agent
608
+ attr_accessor :job
609
+ end
610
+ ```
611
+
612
+ This adds a `job` accessor to every agent. The `agent` helper sets
613
+ `agent.job = self` (the current Step). This allows the agent's `ask` method to
614
+ detect when it is running inside a workflow and route inference through the
615
+ workflow's own `:ask` task (see §11.2).
616
+
617
+ ---
618
+
619
+ ## 11. Job Context Propagation
620
+
621
+ ### 11.1 How context flows into agents
622
+
623
+ When the `agent` helper is called inside a `chat_task` block, the agent receives:
624
+
625
+ 1. **Tooling** — from `self.tooling` (extracted from the input chat)
626
+ 2. **Options** — from `self.options` (extracted from `:option` messages in the chat)
627
+ 3. **Conversation** — from the `chat:` parameter (typically `self.chat`)
628
+ 4. **System context** — CWD, job path, files_dir, dependency paths
629
+ 5. **Files** — from the `files:` parameter, resolved via `Step#file`
630
+
631
+ ### 11.2 How agents route back through workflows
632
+
633
+ When an agent has a `workflow` with an `:ask` task AND has `job` set (meaning it
634
+ is running inside a `chat_task`), its `ask` method does NOT call `LLM.ask`
635
+ directly. Instead:
636
+
637
+ ```ruby
638
+ # From agent.rb, simplified:
639
+ if workflow && workflow.tasks.include?(:ask) && !no_ask_override
640
+ job = workflow.job(:ask, chat: Chat.print(messages))
641
+ self.job = job
642
+ Chat.allow_dir job.files_dir
643
+ Chat.allow_read_dir workflow.directory
644
+ job.produce
645
+ messages = Chat.project(job.short_path, LLM.chat(job.path))
646
+ else
647
+ LLM.ask messages, ...
648
+ end
649
+ ```
650
+
651
+ This creates a **nested workflow job** for each inference call, which:
652
+ - Is itself cached/persisted as a Scout job
653
+ - Has its own `files_dir` for agent-generated artifacts
654
+ - Creates a provenance chain (parent job → ask job)
655
+ - Can be inspected by `ChatAnalyst` and `scout llm prov`
656
+
657
+ The `no_ask_override` flag (set to `true` in the `Critic` workflow's agent
658
+ helper) bypasses this routing and calls `LLM.ask` directly.
659
+
660
+ ### 11.3 Directory permissions
661
+
662
+ The agent's `ask` method calls:
663
+ ```ruby
664
+ Chat.allow_dir job.files_dir # write access for tool calls
665
+ Chat.allow_read_dir workflow.directory # read access for workflow resources
666
+ ```
667
+
668
+ These register thread-local allowed directories that the tool execution sandbox
669
+ respects. This is how agents get controlled filesystem access.
670
+
671
+ ---
672
+
673
+ ## 12. The `chat_task` Lifecycle (End-to-End)
674
+
675
+ ```
676
+ ┌──────────────────────────────────────────────────────────────────────┐
677
+ │ DECLARATION PHASE │
678
+ │ │
679
+ │ chat_task :my_task do │
680
+ │ # 'self' is the Step instance │
681
+ │ # self.chat → parsed Chat from input │
682
+ │ # self.options → LLM options from chat │
683
+ │ # self.tooling → extracted tool/kb/mcp/introduce messages │
684
+ │ agent = self.agent :MyAgent, chat: self.chat │
685
+ │ agent.user "Do something" │
686
+ │ agent # ← return value │
687
+ │ end │
688
+ └───────────────────────────────┬──────────────────────────────────────┘
689
+ │
690
+ ▼
691
+ ┌──────────────────────────────────────────────────────────────────────┐
692
+ │ EXECUTION PHASE (chat_task body) │
693
+ │ │
694
+ │ 1. Input :chat is parsed → self.chat (memoized) │
695
+ │ 2. Block executed via instance_exec │
696
+ │ 3. self.agent(:MyAgent) called: │
697
+ │ a. LLM.load_agent loads agent from filesystem │
698
+ │ b. agent.job = self (current Step) │
699
+ │ c. agent.start_chat populated with: │
700
+ │ - tooling messages (tools, KB, MCP, introductions) │
701
+ │ - system messages (CWD, job path, files_dir, deps) │
702
+ │ - conversation context (filtered chat) │
703
+ │ - file attachments │
704
+ │ 4. agent.user("Do something") adds a user message │
705
+ │ 5. Block returns the agent │
706
+ └───────────────────────────────┬──────────────────────────────────────┘
707
+ │
708
+ ▼
709
+ ┌──────────────────────────────────────────────────────────────────────┐
710
+ │ RESULT PROCESSING (chat_task wrapper) │
711
+ │ │
712
+ │ 6. response = block return value (an LLM::Agent) │
713
+ │ 7. Since last message is :user → agent.chat(return_messages: true) │
714
+ │ a. Agent#ask detects workflow.tasks.include?(:ask) │
715
+ │ b. Creates nested workflow job: workflow.job(:ask, chat: ...) │
716
+ │ c. job.produce → runs inference, persists result │
717
+ │ d. Returns projected messages │
718
+ │ 8. log_agent(agent) called automatically: │
719
+ │ a. Writes log/agent.chat (full conversation) │
720
+ │ b. Writes log/society/*.chat for delegated agents │
721
+ │ c. Updates step info with dependency paths │
722
+ │ 9. Chat.project(self.short_path, result) wraps with provenance meta │
723
+ │ 10. Result saved as <step.path> with .chat extension │
724
+ └──────────────────────────────────────────────────────────────────────┘
725
+ ```
726
+
727
+ ---
728
+
729
+ ## 13. Real Usage Examples from SC26
730
+
731
+ ### 13.1 Planned — Linear Pipeline (`Agent/Planned/workflow.rb`)
732
+
733
+ The canonical multi-step agent pipeline. Each `chat_task` depends on the
734
+ previous one, building up context through `chat.follow`:
735
+
736
+ ```ruby
737
+ module Planned
738
+ extend Workflow
739
+ self.include_workflow AgentWorkflow
740
+
741
+ chat_task :request do
742
+ agent = self.agent :User, chat: chat
743
+ agent.user "Restate the request from the user..."
744
+ agent
745
+ end
746
+
747
+ dep :request
748
+ chat_task :search do
749
+ chat = self.chat
750
+ chat.follow step(:request).load # ← append request's chat output
751
+ agent = self.agent :Searcher, chat: chat, tooling: self.tooling_intro
752
+ agent.user "Prepare a research report..."
753
+ agent
754
+ end
755
+
756
+ dep :search # ← conditional dependency
757
+ chat_task :plan do
758
+ chat = self.chat
759
+ chat.follow step(:request).load.last
760
+ chat.follow step(:search).load.last if step(:search)
761
+ chat.message :clear_tools, true # ← strip tools from context
762
+ agent = self.agent :Planner, chat: chat, tooling: self.tooling_intro
763
+ agent.user "Elaborate a plan..."
764
+ agent
765
+ end
766
+
767
+ dep :plan
768
+ chat_task :work do
769
+ chat = self.chat
770
+ chat.follow step(:request).load.last
771
+ chat.follow step(:plan).load.last
772
+ chat.message :clear_tools, true
773
+ agent = self.agent worker_agent, chat: chat, tooling: self.tooling
774
+ agent.user "Proceed with the plan..."
775
+ agent
776
+ end
777
+
778
+ dep :work
779
+ chat_task :ask do
780
+ chat = self.chat
781
+ chat.follow step(:request).load
782
+ chat.follow step(:plan).load
783
+ chat.follow step(:work).load
784
+ chat.message :clear_tools, true
785
+ agent = self.agent :User, tooling: false, chat: chat # ← no tools
786
+ agent.user "Please elaborate a final report..."
787
+ agent
788
+ end
789
+ end
790
+ ```
791
+
792
+ **Pattern: Linear dependency chain with context accumulation.** Each step:
793
+ 1. Declares `dep :previous_step`
794
+ 2. Follows previous step's output into the chat
795
+ 3. May clear tools to focus the agent
796
+ 4. May use different tooling levels (`tooling_intro` vs `tooling` vs `false`)
797
+
798
+ ### 13.2 Critic — Direct Inference (`Agent/Critic/workflow.rb`)
799
+
800
+ Uses `no_ask_override: true` to bypass workflow routing:
801
+
802
+ ```ruby
803
+ module Critic
804
+ extend Workflow
805
+ self.include_workflow AgentWorkflow
806
+
807
+ chat_task :ask do
808
+ agent = self.agent :Critic, chat: chat, no_ask_override: true
809
+ agent.user "Evaluate the previous work."
810
+ response = agent.chat return_messages: true
811
+ begin
812
+ set_info :json, Chat.parse_json(response.answer)
813
+ rescue
814
+ end
815
+ agent
816
+ end
817
+ end
818
+ ```
819
+
820
+ **Pattern: Structured output with side-effect metadata.** The `set_info` call
821
+ writes parsed JSON to the step's info file, making it queryable via Scout's
822
+ metadata system.
823
+
824
+ ### 13.3 Branched — Parallel Fan-out (`Agent/Branched/workflow.rb`)
825
+
826
+ Uses `log_agent` manually for multiple agents, and `iterate_dictionary` for
827
+ parallel execution:
828
+
829
+ ```ruby
830
+ chat_task :work do
831
+ # ... spliter agent produces a JSON dict of sub-tasks ...
832
+
833
+ reports = {}
834
+ spliter.iterate_dictionary nil, cpus: 8, bar: self.progress_bar('Branches'),
835
+ into: reports do |name, instructions|
836
+ log name, "Start #{name}"
837
+ worker = self.agent worker_agent, chat: plan.dup, tooling: tooling
838
+ worker.user "Produce sub-task (#{name}):\n\n#{instructions}"
839
+ reports[name] = worker.chat
840
+ log_agent worker, "worker-#{name}" # ← manual logging with named subdir
841
+ log name, "Done #{name}"
842
+ [name, worker.chat]
843
+ end
844
+
845
+ log_agent spliter, 'spliter' # ← log the spliter too
846
+
847
+ # ... critic aggregates reports ...
848
+ end
849
+ ```
850
+
851
+ **Pattern: Manual agent management with parallel execution.** Key techniques:
852
+ - `iterate_dictionary` for JSON-dict-driven parallel fan-out
853
+ - `chat: plan.dup` to give each branch an independent copy of the plan
854
+ - Manual `log_agent` calls with distinct names for each branch
855
+ - `self.progress_bar` for visual feedback
856
+
857
+ ### 13.4 Refined — Iterative Loop (`Agent/Refined/workflow.rb`)
858
+
859
+ Uses `TryAgain` exception for control flow:
860
+
861
+ ```ruby
862
+ chat_task :ask do
863
+ worker = self.agent worker_agent, chat: chat, tooling: self.tooling
864
+ critic = self.agent :Critic, chat: chat
865
+
866
+ round = 1
867
+ begin
868
+ worker.user "Execute the work..."
869
+ report = worker.chat
870
+
871
+ critic.user "Below is the workers report"
872
+ critic.user report
873
+ critic.user "Make an evaluation in JSON."
874
+ json = critic.json
875
+ evaluation = IndiferentHash.setup(json)
876
+
877
+ log_agent worker, "worker-round-#{round}"
878
+ log_agent critic, "critic-round-#{round}"
879
+
880
+ case evaluation[:status]
881
+ when 'NEEDS_WORK'
882
+ round += 1
883
+ worker.user "The Critic has done this evaluation..."
884
+ worker.user evaluation.to_json
885
+ worker.message :clear_tools, true
886
+ critic.message :clear_tools, true
887
+ raise TryAgain
888
+ when 'PASS'
889
+ when 'BLOCKED'
890
+ end
891
+ rescue
892
+ retry if TryAgain === $!
893
+ raise $!
894
+ end
895
+
896
+ {role: :assistant, content: evaluation.to_json}
897
+ end
898
+ ```
899
+
900
+ **Pattern: Worker-critic loop with exception-driven retry.** Key techniques:
901
+ - `raise TryAgain` + `retry if TryAgain === $!` for loop control
902
+ - Round-specific `log_agent` names for provenance
903
+ - `:clear_tools` between rounds to manage context
904
+ - Returns a Hash (not an Agent) — the chat_task wrapper handles it
905
+
906
+ ### 13.5 InterpretData — Dynamic Tool Definition (`Agent/InterpretData/workflow.rb`)
907
+
908
+ Uses `agent.workflow do ... end` to define tasks inline:
909
+
910
+ ```ruby
911
+ chat_task :gather do
912
+ analyst = self.agent :Analyst, chat: chat, tooling: self.tooling
913
+
914
+ artifact_dir = file('artifacts')
915
+
916
+ analyst.workflow do
917
+ desc "Write an artifact to file"
918
+ input :name, :string, 'Name of the artifact', nil, required: true
919
+ input :content, :text, 'Content of the artifact', nil, required: true
920
+ task :write_artifact => :string do |name, content|
921
+ artifact_dir[name].write content
922
+ end
923
+ export_exec :write_artifact
924
+ end
925
+
926
+ analyst.user "Analyze the data and save artifacts..."
927
+ analyst
928
+ end
929
+ ```
930
+
931
+ **Pattern: Runtime tool injection.** The `agent.workflow do ... end` block
932
+ calls `LLM::Agent#workflow(&block)` which does `workflow.instance_eval(&block)`,
933
+ adding tasks to the agent's workflow module. These tasks become callable tools
934
+ for the agent. The `export_exec` makes them available as executable tools.
935
+
936
+ ---
937
+
938
+ ## 14. How Tooling Flows Through Workflows to Agents
939
+
940
+ ```
941
+ ┌─────────────────────────────────────────────────────────────────┐
942
+ │ Input Chat (from user or upstream task) │
943
+ │ │
944
+ │ Messages with roles: │
945
+ │ :introduce → "You are the Planner agent..." │
946
+ │ :tool → {"type":"function","function":{...}} │
947
+ │ :kb → knowledge base definitions │
948
+ │ :mcp → MCP server definitions │
949
+ │ :option → "model gpt-4o" │
950
+ │ :user → "Please analyze the data..." │
951
+ │ :assistant → (prior responses) │
952
+ │ :meta → "job=Planned/request/abc123" │
953
+ └───────────────────────────┬─────────────────────────────────────┘
954
+ │
955
+ ▼
956
+ ┌─────────────────────────────────────────────────────────────────┐
957
+ │ chat_task body │
958
+ │ │
959
+ │ self.tooling ──→ removes :tool, :kb, :mcp, :introduce from chat │
960
+ │ returns them as a combined array │
961
+ │ (memoized: extracted once) │
962
+ │ │
963
+ │ self.tooling_intro ──→ subset of tooling: only :introduce msgs │
964
+ │ │
965
+ │ self.options ──→ LLM.options(self.chat) extracts :option msgs │
966
+ │ │
967
+ │ self.agent(:MyAgent, │
968
+ │ chat: self.chat, ← conversation (minus tooling) │
969
+ │ tooling: self.tooling, ← full tooling (default) │
970
+ │ options: self.options ← LLM config (default) │
971
+ │ ) │
972
+ └───────────────────────────┬─────────────────────────────────────┘
973
+ │
974
+ ▼
975
+ ┌─────────────────────────────────────────────────────────────────┐
976
+ │ agent.start_chat (after agent helper) │
977
+ │ │
978
+ │ [tooling messages] ← :tool, :kb, :mcp, :introduce │
979
+ │ [system messages] ← CWD, job path, deps │
980
+ │ [conversation messages] ← filtered chat context │
981
+ │ [file attachments] ← if files: param given │
982
+ │ │
983
+ │ Then agent.user("prompt") adds the task prompt │
984
+ │ Then agent.chat() triggers inference │
985
+ └─────────────────────────────────────────────────────────────────┘
986
+ ```
987
+
988
+ **Key insight:** Tooling is **extracted** from the input chat (mutating it)
989
+ and **selectively re-injected** into each agent. This allows different agents
990
+ in the same pipeline to have different tool sets:
991
+
992
+ | Agent type | `tooling:` param | What the agent gets |
993
+ |---|---|---|
994
+ | Planner | `self.tooling_intro` | Only introductions (understands tools conceptually, can't call them) |
995
+ | Worker | `self.tooling` | Full tooling (can call all tools) |
996
+ | User (final report) | `false` | No tools at all (pure text synthesis) |
997
+ | Critic | (not specified → `self.tooling`) | Full tooling by default |
998
+
999
+ ---
1000
+
1001
+ ## 15. Design Patterns and When to Use AgentWorkflow
1002
+
1003
+ ### 15.1 When to use `chat_task` vs regular `task`
1004
+
1005
+ **Use `chat_task` when:**
1006
+ - The task's primary input is a Chat (conversation context)
1007
+ - The task's output should be a `.chat` file
1008
+ - The task involves agent interaction (loading an agent, sending prompts)
1009
+ - You want automatic provenance tracking via `Chat.project`
1010
+ - You want automatic agent logging via `log_agent`
1011
+
1012
+ **Use regular `task` when:**
1013
+ - The input/output types are different (TSV, JSON, etc.)
1014
+ - The task is pure data transformation with no agent involvement
1015
+ - You need fine-grained control over persistence
1016
+
1017
+ ### 15.2 When to use `include_workflow AgentWorkflow`
1018
+
1019
+ **Always include it when:**
1020
+ - You define `chat_task`s (the task type needs the helpers)
1021
+ - You use `self.agent`, `self.chat`, `self.tooling`, etc.
1022
+
1023
+ **You don't need it when:**
1024
+ - Your workflow has only regular tasks (no agent interaction)
1025
+ - You're using agent delegation purely at the agent level (not through workflows)
1026
+
1027
+ ### 15.3 Provenance patterns
1028
+
1029
+ The AgentWorkflow creates a multi-layered provenance trail:
1030
+
1031
+ ```
1032
+ User Chat
1033
+ └─ Planned/ask/<id>.chat ← final report task
1034
+ └─ Planned/work/<id>.chat ← work task (dep of ask)
1035
+ └─ Worker/ask/<id>.chat ← individual inference calls
1036
+ └─ (LLM.ask cache) ← raw LLM API call cache
1037
+ └─ Planned/plan/<id>.chat ← plan task (dep of ask)
1038
+ └─ Planned/request/<id>.chat ← request task (dep of ask)
1039
+ └─ log/agent.chat ← full agent conversations
1040
+ └─ log/society/*.chat ← delegated agent chats
1041
+ ```
1042
+
1043
+ Each level is independently cached, resumable, and queryable by `ChatAnalyst`
1044
+ and `scout llm prov`.
1045
+
1046
+ ### 15.4 Common idioms
1047
+
1048
+ | Idiom | Code | Purpose |
1049
+ |---|---|---|
1050
+ | Follow dependency output | `chat.follow step(:dep).load` | Append a dependency's chat result |
1051
+ | Follow only the last message | `chat.follow step(:dep).load.last` | Get just the final answer |
1052
+ | Clear tools for focus | `chat.message :clear_tools, true` | Remove tool definitions from context |
1053
+ | Configurable worker agent | `options[:worker_agent] \|\| 'Worker'` | Allow runtime agent selection |
1054
+ | Conditional dependency | `dep :search do \|jobname,options\| ... end` | Skip search if `use_search` is false |
1055
+ | Named agent logging | `log_agent agent, "worker-#{name}"` | Create subdirectories for multiple agents |
1056
+ | Structured metadata | `set_info :json, parsed_json` | Store parsed output in step info |
1057
+ | Exception-driven retry | `raise TryAgain; retry if TryAgain === $!` | Loop control in iterative patterns |
1058
+
1059
+ ---
1060
+
1061
+ ## 16. Method Reference Summary
1062
+
1063
+ | Method | Defined in | Signature | Returns | Auto-called? |
1064
+ |---|---|---|---|---|
1065
+ | `chat_task` | `Workflow` (patched) | `(name, &block)` | — | No (DSL) |
1066
+ | `chat` | `AgentWorkflow` helper | `(chat=nil)` | `Chat` | No |
1067
+ | `options` | `AgentWorkflow` helper | `()` | `Hash` | No |
1068
+ | `agent` | `AgentWorkflow` helper | `(name=nil, chat:nil, options:nil, tooling:nil, files:nil, **kwargs)` | `LLM::Agent` | No |
1069
+ | `agent_options` | `AgentWorkflow` helper | `(options)` | `Hash` | No (internal) |
1070
+ | `tooling` | `AgentWorkflow` helper | `()` | `Array<Hash>` | No |
1071
+ | `tooling_intro` | `AgentWorkflow` helper | `()` | `Array<Hash>` | No |
1072
+ | `log_agent` | `AgentWorkflow` helper | `(agent, agent_name=nil)` | `LLM::Agent` | Yes (by `chat_task` when block returns Agent) |
1073
+ | `add_chat_dependencies` | `AgentWorkflow` helper | `(chat)` | void | No (commented out) |
1074
+ | `Chat.project` | `Chat` class method | `(job, messages)` | `Array<Hash>` | Yes (by `chat_task`) |
1075
+ | `Chat.jobs` / `job_paths` | `Chat` instance | `()` | `Array<Path>` | No |
1076
+
1077
+ ---
1078
+
1079
+ ## 17. Relationship to the Broader Agent Ecosystem
1080
+
1081
+ ```
1082
+ ┌─────────────────────┐
1083
+ │ LLM::Agent │
1084
+ │ (stateful wrapper) │
1085
+ └────────┬────────────┘
1086
+ │
1087
+ ┌───────────────────┼───────────────────┐
1088
+ │ │ │
1089
+ ┌─────────▼──────┐ ┌────────▼────────┐ ┌───────▼────────┐
1090
+ │ agent/chat.rb │ │ agent/delegate │ │ agent/workflow │
1091
+ │ (conversation │ │ (multi-agent │ │ (AgentWorkflow │
1092
+ │ lifecycle) │ │ delegation) │ │ + chat_task) │
1093
+ └────────────────┘ └─────────────────┘ └───────┬────────┘
1094
+ │
1095
+ ┌──────────▼──────────┐
1096
+ │ Scout Workflow │
1097
+ │ (persistence, │
1098
+ │ dependencies, │
1099
+ │ provenance, │
1100
+ │ scheduling) │
1101
+ └─────────────────────┘
1102
+ ```
1103
+
1104
+ `AgentWorkflow` is the **integration layer** that connects the Agent abstraction
1105
+ to Scout's Workflow system. It provides:
1106
+ - **Task abstraction:** `chat_task` makes agent conversations first-class
1107
+ workflow steps
1108
+ - **Persistence:** Results are automatically saved as `.chat` files with
1109
+ proper serialization
1110
+ - **Provenance:** `Chat.project` + `log_agent` create a complete evidence trail
1111
+ - **Composability:** Dependencies between chat tasks enable complex pipelines
1112
+ - **Configurability:** Tooling and options flow through the chat input,
1113
+ allowing runtime configuration
1114
+
1115
+ ---
1116
+
1117
+ *This document covers `lib/scout/llm/agent/workflow.rb` in full and cross-references
1118
+ all related source files. For the broader agent class and delegation mechanics,
1119
+ see `03_agent_and_delegation.md`. For multi-agent orchestration patterns, see
1120
+ `08_multi_agent_patterns.md`.*