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,810 @@
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
+ # 03 — Agent Class, Delegation Mechanics, and Socialization
10
+
11
+ > **Source files analysed**
12
+ > - `lib/scout/llm/agent.rb` (213 lines)
13
+ > - `lib/scout/llm/agent/chat.rb` (110 lines)
14
+ > - `lib/scout/llm/agent/delegate.rb` (323 lines)
15
+ > - `lib/scout/llm/agent/iterate.rb` (44 lines)
16
+ > - `lib/scout/llm/agent/workflow.rb` (workflow integration helper)
17
+ > - `lib/scout/llm/ask.rb` (entry-point `LLM.ask`)
18
+ > - `lib/scout/llm/chat/annotation.rb`, `chat/process/tools.rb`, `chat/process/clear.rb`, `chat/prompt.rb`
19
+
20
+ ---
21
+
22
+ ## 1. Agent Class Structure
23
+
24
+ ### 1.1 Definition and composition
25
+
26
+ `LLM::Agent` is the central Ruby class that represents an autonomous AI agent.
27
+ It is defined in `lib/scout/llm/agent.rb` and extended by three mixin modules
28
+ loaded at the bottom of the file:
29
+
30
+ ```ruby
31
+ require_relative 'agent/chat'
32
+ require_relative 'agent/iterate'
33
+ require_relative 'agent/delegate'
34
+ require_relative 'agent/workflow'
35
+ ```
36
+
37
+ Each module adds a cohesive set of instance methods to the same `LLM::Agent`
38
+ class — a classic Ruby module-composition / "concern" pattern:
39
+
40
+ | Module file | Responsibility |
41
+ |---|---|
42
+ | `agent/chat.rb` | Conversation lifecycle: `start`, `current_chat`, `chat`, `json`, etc. |
43
+ | `agent/iterate.rb` | Structured multi-step extraction (`iterate`, `iterate_dictionary`). |
44
+ | `agent/delegate.rb` | Multi-agent socialization & delegation (`socialize`, `delegate`, `ask_agent`). |
45
+ | `agent/workflow.rb` | Integration with Scout's `Workflow` system (`chat_task`, `AgentWorkflow`). |
46
+
47
+ The top-level file also defines two convenience module methods on `LLM`:
48
+
49
+ ```ruby
50
+ def self.agent(...) = LLM::Agent.new(...) # factory shortcut
51
+ def self.load_agent(...) = LLM::Agent.load_agent(...) # discovery + loading
52
+ ```
53
+
54
+ ### 1.2 Initialization
55
+
56
+ ```ruby
57
+ def initialize(workflow: nil, knowledge_base: nil, start_chat: nil, **kwargs)
58
+ ```
59
+
60
+ | Parameter | Type | Purpose |
61
+ |---|---|---|
62
+ | `workflow:` | `Workflow` module or name string | Scout workflow whose tasks become callable tools. If a `String`, it is resolved via `Workflow.require_workflow`. |
63
+ | `knowledge_base:` | `KnowledgeBase` | Optional knowledge base; its databases are exposed as tools. |
64
+ | `start_chat:` | `Chat` (Array of message hashes) | The seeded / system conversation that prefixes every new chat branch. |
65
+ | `**kwargs` | — | Captured into `@other_options` as an `IndiferentHash`. Typically holds `:model`, `:endpoint`, `:tools`, etc. |
66
+
67
+ ### 1.3 Core attributes (attr\_accessor)
68
+
69
+ ```ruby
70
+ attr_accessor :workflow, :knowledge_base, :start_chat,
71
+ :process_exception, :other_options, :path, :job
72
+ ```
73
+
74
+ Additional attributes from mixins:
75
+
76
+ | Attribute | Defined in | Purpose |
77
+ |---|---|---|
78
+ | `@society` | `delegate.rb` | Hash of `{agent_name => Agent}` templates loaded once and cloned per conversation. |
79
+ | `@chats` | `delegate.rb` | Hash of `{agent_name/conversation => Agent}` — the live specialist instances. |
80
+ | `@current_chat` | `chat.rb` (lazy via `current_chat`) | The active conversation (a `Chat`-annotated array). |
81
+
82
+ ### 1.4 Lazy workflow creation
83
+
84
+ If no `@workflow` is set, one is created on demand:
85
+
86
+ ```ruby
87
+ def workflow(&block)
88
+ if block_given?
89
+ # evaluate block in the workflow's context (DSL)
90
+ workflow.instance_eval &block
91
+ else
92
+ @workflow ||= begin
93
+ m = Module.new
94
+ m.extend Workflow
95
+ m.name ||= 'ScoutAgent'
96
+ m.tasks = {}
97
+ m
98
+ end
99
+ end
100
+ end
101
+ ```
102
+
103
+ This allows inline workflow definition in tests or scripts:
104
+
105
+ ```ruby
106
+ agent.workflow do
107
+ task :my_task => :string do ... end
108
+ end
109
+ ```
110
+
111
+ ---
112
+
113
+ ## 2. The `ask` / `iterate` Loop
114
+
115
+ ### 2.1 `Agent#ask` — entry point for inference
116
+
117
+ ```ruby
118
+ def ask(messages = nil, options = {})
119
+ ```
120
+
121
+ **Key behaviour:**
122
+
123
+ 1. **Message resolution.** If `messages` is nil, uses `current_chat`. Normalises
124
+ to an array.
125
+ 2. **Socialize hook.** If any message has `role: 'socialize'` and its content is
126
+ truthy (`true`, `T`, `1`), calls `self.socialize(options.dup)` — wiring up
127
+ the `ask` tool so the LLM can delegate.
128
+ 3. **Tool merging.** Merges three layers of tool definitions:
129
+ - Explicit `options[:tools]`
130
+ - `@other_options[:tools]` (e.g. tools added by `socialize`/`delegate`)
131
+ - Workflow tools (`LLM.workflow_tools(workflow)`) and knowledge-base tools.
132
+ 4. **Two execution paths:**
133
+
134
+ **Path A — Workflow `ask` task (preferred for agent-backed workflows):**
135
+ ```ruby
136
+ if workflow && workflow.tasks.include?(:ask) && !no_ask_override
137
+ job = workflow.job(:ask, chat: Chat.print(messages))
138
+ job.produce
139
+ messages = Chat.project(job.short_path, LLM.chat(job.path))
140
+ ```
141
+ The agent dispatches through the workflow's own `ask` task (a `chat_task`),
142
+ gaining Scout's job caching, provenance, and dependency system.
143
+
144
+ **Path B — Direct `LLM.ask`:**
145
+ ```ruby
146
+ LLM.ask messages, @other_options.merge(log_errors: true).merge(options).merge(agent: false)
147
+ ```
148
+ Calls the backend directly without going through a workflow job.
149
+
150
+ 5. **Exception handling.** Wraps everything in a `begin/rescue`; if
151
+ `@process_exception` is a `Proc`, it is called with the exception and may
152
+ trigger a `retry`.
153
+
154
+ ### 2.2 The multi-turn tool-calling loop (backend level)
155
+
156
+ The iterative tool-calling loop does **not** live in `Agent` itself — it lives
157
+ in the backend layer (`LLM::Backend::Default#chain_tools`):
158
+
159
+ ```ruby
160
+ def chain_tools(messages, output, tools, options = {}, &block)
161
+ if output.last[:role] == 'function_call_output'
162
+ # re-call ask with the tool output appended
163
+ output + ask(messages + output, options.except(:tool_choice).merge(return_messages: true), &block)
164
+ else
165
+ output # no pending tool call — done
166
+ end
167
+ end
168
+ ```
169
+
170
+ This is **recursion**: each backend `ask` call checks whether the model emitted
171
+ a `function_call_output`; if so, it calls `ask` again with the growing message
172
+ list. The loop terminates when the model's last message is a plain `assistant`
173
+ message rather than a tool call.
174
+
175
+ **Iteration limits** are enforced via the prompt shortening system
176
+ (`lib/scout/llm/chat/prompt.rb`):
177
+
178
+ | Constant | Default | Meaning |
179
+ |---|---|---|
180
+ | `DEFAULT_MAX_TOOL_CALLS` | 40 | Maximum number of tool call/output pairs retained in the prompt. |
181
+ | `DEFAULT_FULL_TOOL_CALLS` | 0 | Number of most-recent tool calls kept at full fidelity. |
182
+ | `DEFAULT_FULL_TOOL_OUTPUTS` | 10 | Number of most-recent tool outputs kept at full fidelity. |
183
+ | `DEFAULT_MAX_TOOL_CHARS` | 100 000 | Character budget for tool outputs. |
184
+
185
+ Older tool calls/outputs beyond these limits are truncated or dropped, which
186
+ effectively bounds the conversation depth and prevents unbounded recursion.
187
+
188
+ ### 2.3 `Agent#prompt`
189
+
190
+ ```ruby
191
+ def prompt(messages, options = {})
192
+ messages = LLM.chat messages if String === messages
193
+ messages = Chat.follow start_chat, messages # prefix with start_chat
194
+ ask messages, options
195
+ end
196
+ ```
197
+
198
+ Convenience method: parses a string as chat syntax, prepends the agent's
199
+ `start_chat`, then delegates to `ask`.
200
+
201
+ ### 2.4 `Agent#iterate` (iterate.rb)
202
+
203
+ ```ruby
204
+ def iterate(prompt = nil, &block)
205
+ self.endpoint :responses
206
+ self.user prompt if prompt
207
+ obj = self.json_format({ ... "type": "object", "properties": { "content": { "type": "array", "items": {"type": "string" } } } ... })
208
+ self.option :format, :text
209
+ list = Hash === obj ? obj['content'] : obj
210
+ list.each &block
211
+ end
212
+ ```
213
+
214
+ A structured-extraction loop: sends a prompt, asks the model to return a JSON
215
+ array of strings, then iterates over each element calling the supplied block.
216
+ `iterate_dictionary` is the same pattern but returns a flat key/value hash.
217
+
218
+ ---
219
+
220
+ ## 3. Agent Chat Management (agent/chat.rb)
221
+
222
+ ### 3.1 The dual-chat model
223
+
224
+ Every `Agent` maintains two Chat objects:
225
+
226
+ | Chat | Variable | Purpose |
227
+ |---|---|---|
228
+ | **Start chat** | `@start_chat` | Immutable seed messages (system instructions, tool intros, files). Prefixes every new conversation. |
229
+ | **Current chat** | `@current_chat` | The live, evolving conversation. |
230
+
231
+ ### 3.2 `start_chat` accessor
232
+
233
+ ```ruby
234
+ def start_chat
235
+ @start_chat ||= Chat.setup([])
236
+ end
237
+ ```
238
+
239
+ Defaults to an empty chat if none was provided at construction.
240
+
241
+ ### 3.3 `start` — creating a new conversation branch
242
+
243
+ ```ruby
244
+ def start(chat = nil)
245
+ if chat
246
+ (@current_chat || start_chat).annotate chat unless Chat === chat
247
+ @current_chat = chat
248
+ else
249
+ start_chat_obj = self.start_chat
250
+ Chat.setup(start_chat_obj) unless Chat === start_chat_obj
251
+ @current_chat = start_chat_obj.branch # shallow copy via annotate(self.dup)
252
+ end
253
+ end
254
+ ```
255
+
256
+ - With no argument: creates a **branch** (shallow copy) of `start_chat` and
257
+ assigns it to `@current_chat`.
258
+ - With an argument: adopts the provided chat as the current chat (annotating it
259
+ to ensure it behaves as a `Chat`).
260
+
261
+ ### 3.4 `current_chat`
262
+
263
+ ```ruby
264
+ def current_chat
265
+ @current_chat ||= start
266
+ end
267
+ ```
268
+
269
+ Lazy: on first access it calls `start` to create the default branch.
270
+
271
+ ### 3.5 `method_missing` — Chat proxy
272
+
273
+ ```ruby
274
+ def method_missing(name, ...)
275
+ current_chat.send(name, ...)
276
+ end
277
+ ```
278
+
279
+ Any method not defined on `Agent` is forwarded to `current_chat`. This means
280
+ calls like `agent.user("hi")`, `agent.system("...")`, `agent.option(:model,
281
+ "gpt-4")`, `agent.print` are all delegated to the underlying Chat object.
282
+
283
+ ### 3.6 `chat` — one round-trip with history
284
+
285
+ ```ruby
286
+ def chat(options = {})
287
+ response = ask(current_chat, options.merge(return_messages: true))
288
+ if Array === response
289
+ current_chat.concat(response)
290
+ options[:return_messages] ? response : current_chat.answer
291
+ else
292
+ current_chat.push({role: :assistant, content: response})
293
+ response
294
+ end
295
+ end
296
+ ```
297
+
298
+ Calls `ask` with `return_messages: true`, appends the response messages to the
299
+ current chat, and returns either the full message list or just the answer text.
300
+
301
+ ### 3.7 JSON helpers
302
+
303
+ `json` and `json_format` push a format constraint onto the chat, call `chat`,
304
+ parse the output as JSON, and restore the format. They provide structured
305
+ extraction.
306
+
307
+ ---
308
+
309
+ ## 4. Socialization and Delegation (delegate.rb) — CRITICAL
310
+
311
+ This module (323 lines) implements Scout-AI's multi-agent architecture. It
312
+ allows one Agent to **socialize** (expose a generic `ask` tool to the LLM) or
313
+ **delegate** (create named `hand_off_to_*` tools for specific agents).
314
+
315
+ ### 4.1 Constants and invariants
316
+
317
+ ```ruby
318
+ SOCIAL_INHERIT_MODES = %w[none tools conversation].freeze
319
+ SOCIAL_AGENT_NAME = /\A[a-z_.-]+\z/i
320
+ SOCIAL_CONVERSATION_NAME = /\A[a-z0-9][a-z0-9_.-]*\z/i
321
+ SOCIAL_PRIVATE_OPTIONS = %i[
322
+ agent client current_meta format messages no_ask_override
323
+ previous_response_id process return_messages tool_choice tools
324
+ ].freeze
325
+ ```
326
+
327
+ - **`SOCIAL_AGENT_NAME`** — valid agent name pattern (letters, dots,
328
+ underscores, hyphens).
329
+ - **`SOCIAL_CONVERSATION_NAME`** — conversation identifiers must start with
330
+ alphanumeric.
331
+ - **`SOCIAL_PRIVATE_OPTIONS`** — caller options that are **stripped** before
332
+ being passed to a specialist (prevents leaking session state, tool blocks,
333
+ or message arrays).
334
+
335
+ ### 4.2 SOCIAL\_INHERIT\_MODES
336
+
337
+ These three modes control **how much caller context** flows to a specialist
338
+ when a new call or conversation is first created:
339
+
340
+ | Mode | What is inherited | Use case |
341
+ |---|---|---|
342
+ | **`none`** | Nothing. The specialist starts only with its own `start_chat`. | Fully isolated sub-agent. |
343
+ | **`tools`** *(default)* | Only the declarative tooling (roles: `introduce`, `tool`, `mcp`, `kb`) from the caller's current chat. | Give the specialist the same tool capabilities without conversation history. |
344
+ | **`conversation`** | The caller's entire current chat minus its own start\_chat prefix. | Full context sharing for deeply collaborative work. |
345
+
346
+ Implemented in `social_inherited_context`:
347
+
348
+ ```ruby
349
+ def social_inherited_context(inherit)
350
+ case inherit
351
+ when 'none'
352
+ Chat.setup([])
353
+ when 'tools'
354
+ tooling = self.current_chat.tooling
355
+ social_chat_copy(tooling)
356
+ when 'conversation'
357
+ social_caller_context
358
+ end
359
+ end
360
+ ```
361
+
362
+ ### 4.3 The `socialize` method
363
+
364
+ **Signature:**
365
+
366
+ ```ruby
367
+ def socialize(options = {})
368
+ ```
369
+
370
+ **What it does:** Registers a single tool named `:ask` in `@other_options[:tools]`.
371
+ When the LLM invokes this tool, it can ask **any** specialist agent.
372
+
373
+ **Tool schema exposed to the model:**
374
+
375
+ | Parameter | Type | Required | Description |
376
+ |---|---|---|---|
377
+ | `agent` | string | ✅ | Name of the specialist agent. |
378
+ | `prompt` | string | ✅ | Plain-text prompt (one user message). |
379
+ | `conversation` | string | ❌ | Named conversation identifier. Omit for one-shot. Reuse to continue. |
380
+ | `inherit` | enum `[none, tools, conversation]` | ❌ (default `tools`) | Context policy for new calls/conversations only. |
381
+
382
+ **Tool block (executed when the LLM calls `ask`):**
383
+
384
+ ```ruby
385
+ block = Proc.new do |_name, parameters|
386
+ agent_name, prompt, conversation, inherit = social_tool_parameters(parameters)
387
+ ask_agent(agent_name, prompt,
388
+ conversation: conversation,
389
+ inherit: inherit,
390
+ options: social_options)
391
+ end
392
+ ```
393
+
394
+ The model **never sees** the specialist's `Chat` object — it receives only the
395
+ text answer. The block captures `social_options` (a deep-duplicated copy of the
396
+ caller's `other_options` minus private keys) in its closure.
397
+
398
+ ### 4.4 The `ask_agent` method — the delegation engine
399
+
400
+ **Signature:**
401
+
402
+ ```ruby
403
+ def ask_agent(agent_name, prompt, conversation: nil, inherit: 'tools', options: {})
404
+ ```
405
+
406
+ **Flow:**
407
+
408
+ 1. Validate `agent_name` and `inherit`.
409
+ 2. Resolve the specialist instance:
410
+ - If `conversation` is nil → uses conversation key `'default'` (a
411
+ single persistent conversation per agent, effectively shared across
412
+ one-shot calls).
413
+ - If `conversation` is provided → uses that named conversation.
414
+ 3. `agent.user(prompt)` — appends the prompt as a user message.
415
+ 4. Returns the specialist `Agent` object (the caller's tool block then calls
416
+ `agent.chat` to get the text response, or the socialize block does this
417
+ internally).
418
+
419
+ > **Security note (from source comment):** `ask_agent` uses `agent.user(prompt)`
420
+ > rather than `agent.prompt(prompt)` because `prompt` parses String input as
421
+ > Scout chat-file syntax — a malicious or confused prompt containing `tool:`
422
+ > or `system:` directives could inject control messages or grant tools.
423
+ > `user` simply appends a single user-role message.
424
+
425
+ ### 4.5 The `load_chat` method — conversation scoping
426
+
427
+ ```ruby
428
+ def load_chat(agent_name, options = {}, conversation = nil, inherit: 'tools')
429
+ key = social_chat_key(agent_name, conversation) # "Worker/work_A"
430
+ @chats[key] ||= start_social_chat(agent_name, options, inherit)
431
+ end
432
+ ```
433
+
434
+ **Conversation keys are scoped by agent:** `Worker/work_A` and `Critic/work_A`
435
+ are completely independent conversations. The `@chats` hash persists specialist
436
+ instances across calls within the same caller agent.
437
+
438
+ `inherit` is only consulted **once** — when the conversation is first created.
439
+ Follow-up turns reuse the existing conversation with its accumulated history.
440
+
441
+ ### 4.6 `start_social_chat` — the full initialization
442
+
443
+ ```ruby
444
+ def start_social_chat(agent_name, options, inherit)
445
+ template = load_agent(agent_name, options) # load specialist template
446
+ agent = clone_social_agent(template) # deep clone
447
+ initial_chat = social_chat_copy(agent.start_chat) # copy start chat
448
+ initial_chat.follow(social_inherited_context(inherit)) # append inherited ctx
449
+ agent.start_chat.follow(initial_chat) # set as new start_chat
450
+ agent
451
+ end
452
+ ```
453
+
454
+ This means the specialist's `start_chat` is rebuilt as:
455
+
456
+ ```
457
+ [specialist's original start_chat] + [inherited context from caller]
458
+ ```
459
+
460
+ So the specialist always gets its own system prompt first, then optionally the
461
+ caller's tools or full conversation.
462
+
463
+ ### 4.7 `clone_social_agent` — template isolation
464
+
465
+ ```ruby
466
+ def clone_social_agent(template)
467
+ agent = template.clone
468
+ agent.start_chat = social_chat_copy(template.start_chat)
469
+ agent.other_options = IndiferentHash.setup(social_duplicate(template.other_options || {}))
470
+ agent.society = nil
471
+ agent.chats = nil
472
+ agent.instance_variable_set(:@current_chat, nil)
473
+ agent
474
+ end
475
+ ```
476
+
477
+ Every conversation gets a **fresh clone** of the loaded template, with its own
478
+ `start_chat`, `other_options`, and nilled-out `society`/`chats` (preventing
479
+ accidental cross-contamination of delegation state).
480
+
481
+ ### 4.8 `load_agent` (instance method) — specialist loading
482
+
483
+ ```ruby
484
+ def load_agent(agent_name, options = {})
485
+ agent_name = normalize_social_agent_name(agent_name)
486
+ @society ||= {}
487
+ @society[agent_name] ||= LLM.load_agent(agent_name, social_agent_options(options))
488
+ end
489
+ ```
490
+
491
+ **One immutable template per specialist.** The template is loaded once and
492
+ cloned per-conversation. `social_agent_options` strips private options:
493
+
494
+ ```ruby
495
+ def social_agent_options(options)
496
+ merged = defaults.merge(supplied)
497
+ SOCIAL_PRIVATE_OPTIONS.each { |name| merged.delete(name) }
498
+ merged
499
+ end
500
+ ```
501
+
502
+ ### 4.9 `social_caller_context` — extracting non-start-chat messages
503
+
504
+ ```ruby
505
+ def social_caller_context
506
+ current = current_chat || []
507
+ base = start_chat || []
508
+ base_ids = base.each_with_object({}) { |m, ids| ids[m.object_id] = true }
509
+
510
+ if current.any? { |m| base_ids[m.object_id] }
511
+ # Fast path: same Hash objects — reject by object_id
512
+ current.reject { |m| base_ids[m.object_id] }
513
+ else
514
+ # Fallback: prefix matching for separately parsed Chats
515
+ prefix = 0
516
+ limit = [current.length, base.length].min
517
+ prefix += 1 while prefix < limit && current[prefix] == base[prefix]
518
+ current.drop(prefix)
519
+ end
520
+ end
521
+ ```
522
+
523
+ This extracts the "new" messages — everything the caller has added beyond its
524
+ own `start_chat` — for `inherit: 'conversation'` mode.
525
+
526
+ ### 4.10 The `chat` / `conversation` parameter semantics
527
+
528
+ The old `chat` parameter (from earlier versions) is silently accepted for
529
+ backward compatibility via `social_tool_parameters`:
530
+
531
+ | Legacy `chat` value | Maps to `conversation` | Maps to `inherit` |
532
+ |---|---|---|
533
+ | `'current'` | `'current'` | `'conversation'` |
534
+ | `''`, `'none'`, `'false'` | `nil` (one-shot) | `'none'` |
535
+ | any other name | that name | `'tools'` |
536
+
537
+ New code should use `conversation` and `inherit` as separate parameters.
538
+
539
+ ### 4.11 The `delegate` method — named hand-off tools
540
+
541
+ **Signature:**
542
+
543
+ ```ruby
544
+ def delegate(agent, name, description, task_name = nil, &block)
545
+ ```
546
+
547
+ **What it does:** Creates a tool named `hand_off_to_#{name}` (e.g.,
548
+ `hand_off_to_worker`) that delegates to a specific, pre-loaded `Agent` object.
549
+
550
+ **Default tool block:**
551
+
552
+ ```ruby
553
+ block ||= Proc.new do |_name, parameters|
554
+ message = parameters[:message]
555
+ new_conversation = parameters[:new_conversation]
556
+ agent.start if new_conversation # reset conversation
557
+ agent.user message
558
+ agent.chat # get response
559
+ end
560
+ ```
561
+
562
+ **Tool schema:**
563
+
564
+ | Parameter | Type | Required | Description |
565
+ |---|---|---|---|
566
+ | `message` | string | ✅ | Message to pass to the agent. |
567
+ | `new_conversation` | boolean | ❌ (default false) | If true, erase history and start fresh. |
568
+
569
+ **Key difference from `socialize`:**
570
+
571
+ | Aspect | `socialize` | `delegate` |
572
+ |---|---|---|
573
+ | Agent name | Model chooses at call time (`agent` param) | Hard-coded at registration time |
574
+ | Tool name | `:ask` (single tool for all agents) | `hand_off_to_#{name}` (one tool per agent) |
575
+ | Custom block | No (fixed block) | Yes (caller can supply `&block`) |
576
+ | Conversation management | Named conversations via `conversation` param | Single conversation, resettable via `new_conversation` |
577
+
578
+ ### 4.12 Deep-duplication via `social_duplicate`
579
+
580
+ ```ruby
581
+ def social_duplicate(value)
582
+ case value
583
+ when Hash then value.each_with_object({}) { |(k, v), h| h[social_duplicate(k)] = social_duplicate(v) }
584
+ when Array then value.collect { |item| social_duplicate(item) }
585
+ when String then value.dup
586
+ else value
587
+ end
588
+ end
589
+ ```
590
+
591
+ A recursive deep-copy that avoids `Marshal.load/dump` — important because tool
592
+ blocks (Procs) cannot be marshalled but are simply passed by reference (they
593
+ fall into the `else` branch).
594
+
595
+ ---
596
+
597
+ ## 5. Agent Loading
598
+
599
+ ### 5.1 `LLM::Agent.load_agent` — the class method
600
+
601
+ **Signature:**
602
+
603
+ ```ruby
604
+ def self.load_agent(agent_name = nil, options = {})
605
+ ```
606
+
607
+ **Resolution order** (first match wins):
608
+
609
+ 1. **Direct file path.** If `agent_name` is a filename:
610
+ - If it's a directory containing `agent.rb` → `load` that file.
611
+ - If it's a `.rb` file → `load` it directly.
612
+
613
+ 2. **Named agent discovery** (when `agent_name` is a name string):
614
+ ```ruby
615
+ workflow_path = Scout.workflows[agent_name] # Scout workflows dir
616
+ agent_path = Scout.Agent[agent_name] # Scout Agent dir
617
+ agent_path = Scout.var.Agent[agent_name] unless agent_path.exists?
618
+ agent_path = Scout.chats.Agent[agent_name] unless agent_path.exists?
619
+ agent_path = Scout.chats[agent_name] unless agent_path.exists?
620
+ ```
621
+
622
+ 3. **Workflow resolution:**
623
+ - If `workflow_path` exists → `Workflow.require_workflow(agent_name)`.
624
+ - If `agent_path/workflow.rb` exists → load that file.
625
+ - If `agent_path/python/*.py` exists → load as a Python workflow via
626
+ `PythonWorkflow.load_directory`.
627
+
628
+ 4. **Knowledge base resolution:**
629
+ - `agent_path/knowledge_base` → `KnowledgeBase.load`.
630
+ - Or `workflow_path/knowledge_base`.
631
+
632
+ 5. **Start chat resolution:**
633
+ - `agent_path/start_chat` → `Chat.setup(LLM.chat(file))`.
634
+ - Or `workflow_path/start_chat`.
635
+ - Or, if the workflow has documentation, `[{role: 'introduce', content: workflow.name}]`.
636
+
637
+ ### 5.2 The agent directory convention
638
+
639
+ A named agent is discovered as a directory that may contain:
640
+
641
+ ```
642
+ Agent/
643
+ Worker/
644
+ agent.rb # Ruby file defining the agent (loaded via `load`)
645
+ workflow.rb # Scout Workflow definition
646
+ knowledge_base/ # KnowledgeBase directory
647
+ start_chat # Initial chat in Scout chat-file syntax
648
+ python/ # Python workflow files (*.py)
649
+ ```
650
+
651
+ The lookup chain `Scout.workflows → Scout.Agent → Scout.var.Agent →
652
+ Scout.chats.Agent → Scout.chats` provides multiple well-known locations.
653
+
654
+ ### 5.3 `load_from_path` — struct-path-based loading
655
+
656
+ ```ruby
657
+ def self.load_from_path(path, workflow: nil, knowledge_base: nil, chat: nil)
658
+ ```
659
+
660
+ Used when you have a `Path` object (Scout's Pathwise extension) with
661
+ sub-paths: `path['workflow.rb']`, `path['knowledge_base']`,
662
+ `path['start_chat']`. Each is checked for existence and loaded if present.
663
+
664
+ ### 5.4 Instance-level `load_agent` (delegate.rb)
665
+
666
+ The `delegate.rb` module defines an **instance method** `load_agent` that wraps
667
+ the class method with socialization-specific option filtering:
668
+
669
+ ```ruby
670
+ def load_agent(agent_name, options = {})
671
+ @society[agent_name] ||= LLM.load_agent(agent_name, social_agent_options(options))
672
+ end
673
+ ```
674
+
675
+ This shadows the class method within instances that have mixed in the delegate
676
+ module (which is always, since `delegate.rb` is always loaded).
677
+
678
+ ---
679
+
680
+ ## 6. Key Abstractions and Design Patterns
681
+
682
+ ### 6.1 Module composition (Ruby concerns)
683
+
684
+ The four `agent/*.rb` files all reopen `LLM::Agent` and add methods. There is no
685
+ inheritance hierarchy — just flat module inclusion. This keeps each concern in
686
+ its own file while sharing `@other_options`, `@current_chat`, etc.
687
+
688
+ ### 6.2 `method_missing` proxy to Chat
689
+
690
+ `Agent#method_missing` forwards unknown method calls to `current_chat`, making
691
+ `Agent` a transparent proxy for Chat operations. This is a deliberate DSL
692
+ choice: `agent.user(...)`, `agent.system(...)`, `agent.print`, etc. all "just
693
+ work" without explicit delegation methods.
694
+
695
+ ### 6.3 `IndiferentHash` for option passing
696
+
697
+ Scout's `IndiferentHash` (symbol/string-indifferent access) is used everywhere
698
+ for `options` and `@other_options`, allowing both `:model` and `'model'` keys.
699
+
700
+ ### 6.4 The start\_chat / current\_chat branch pattern
701
+
702
+ ```
703
+ start_chat (immutable seed)
704
+ │
705
+ ├── branch → current_chat (conversation A)
706
+ ├── branch → current_chat (conversation B) [via start(chat)]
707
+ └── ...
708
+ ```
709
+
710
+ `Chat#branch` does `self.annotate(self.dup)` — a shallow copy. The `start_chat`
711
+ is the persistent prefix; `current_chat` is the working copy.
712
+
713
+ ### 6.5 Template + clone pattern for multi-agent
714
+
715
+ ```
716
+ @society (templates) @chats (live instances)
717
+ ──────────────────── ──────────────────────
718
+ "Worker" → Agent (template) "Worker/default" → Agent (clone)
719
+ "Critic" → Agent (template) "Worker/analysis_1" → Agent (clone)
720
+ "Critic/default" → Agent (clone)
721
+ ```
722
+
723
+ Templates are loaded once (`LLM.load_agent`). Each named conversation gets a
724
+ deep clone (`clone_social_agent`) so their `start_chat`, `other_options`, and
725
+ conversation state are fully independent.
726
+
727
+ ### 6.6 Inheritance modes as a flexibility knob
728
+
729
+ The three `SOCIAL_INHERIT_MODES` create a spectrum of coupling:
730
+
731
+ ```
732
+ none → fully sandboxed specialist (no caller context)
733
+ tools → shared capabilities, private history (default)
734
+ conversation → shared everything (tightly coupled pair)
735
+ ```
736
+
737
+ This lets an orchestrator agent control how much context each specialist
738
+ receives on a per-call basis — the model itself can choose `inherit` per tool
739
+ invocation.
740
+
741
+ ### 6.7 Scout chat-file syntax as a security boundary
742
+
743
+ `ask_agent` deliberately uses `agent.user(prompt)` instead of
744
+ `agent.prompt(prompt)` because `prompt` parses chat-file syntax, which could
745
+ allow prompt injection to escalate privileges (e.g., injecting `tool:` lines).
746
+ The `user` method only appends a single `user`-role message, making delegation
747
+ safe even with untrusted LLM-generated prompts.
748
+
749
+ ### 6.8 Workflow integration via `chat_task`
750
+
751
+ `agent/workflow.rb` defines `Workflow#chat_task` which creates Scout workflow
752
+ tasks that:
753
+
754
+ 1. Accept a `chat` input (Scout chat-file format).
755
+ 2. Load an agent via the `agent` helper.
756
+ 3. Run the agent to completion.
757
+ 4. Project the result back with `Chat.project(job.short_path, result)`.
758
+ 5. Log delegated agent chats via `log_agent`.
759
+
760
+ This bridges the Agent abstraction into Scout's dependency-tracked,
761
+ cacheable workflow execution model.
762
+
763
+ ### 6.9 Context truncation as implicit iteration limiting
764
+
765
+ Rather than a hard loop counter, the system bounds multi-turn depth through
766
+ `Chat.shorten_tools` in `prompt.rb`: tool calls/outputs beyond
767
+ `MAX_TOOL_CALLS` (40) are truncated, and those beyond `MAX_TOOL_OUTPUTS` are
768
+ dropped. This naturally constrains the context window and indirectly limits how
769
+ many tool-call rounds a conversation can sustain before the model "forgets"
770
+ earlier tool outputs.
771
+
772
+ ---
773
+
774
+ ## Appendix: Method Reference Table
775
+
776
+ ### Public methods added by each module
777
+
778
+ | Method | Source | Purpose |
779
+ |---|---|---|
780
+ | `ask(messages, options)` | `agent.rb` | Core inference entry point. |
781
+ | `prompt(messages, options)` | `agent.rb` | Parse string as chat, prefix with start\_chat, then `ask`. |
782
+ | `start(chat)` | `chat.rb` | Create/reset the current conversation. |
783
+ | `current_chat` | `chat.rb` | Lazy accessor for the active conversation. |
784
+ | `chat(options)` | `chat.rb` | One round-trip, appending to current\_chat. |
785
+ | `respond(...)` | `chat.rb` | Alias for `ask(current_chat, ...)`. |
786
+ | `json(...)` / `json_format(...)` | `chat.rb` | Structured JSON extraction. |
787
+ | `iterate(prompt, &block)` | `iterate.rb` | Extract a JSON array, iterate over it. |
788
+ | `iterate_dictionary(prompt, &block)` | `iterate.rb` | Extract a JSON dict, traverse it. |
789
+ | `socialize(options)` | `delegate.rb` | Register the generic `ask` tool for multi-agent delegation. |
790
+ | `delegate(agent, name, desc, &block)` | `delegate.rb` | Register a named `hand_off_to_*` tool. |
791
+ | `ask_agent(name, prompt, ...)` | `delegate.rb` | Programmatic delegation to a specialist. |
792
+ | `load_agent(name, options)` | `delegate.rb` | Load (or retrieve cached) specialist template. |
793
+ | `load_chat(name, options, conv, inherit:)` | `delegate.rb` | Get/create a named specialist conversation. |
794
+
795
+ ### Private methods in delegate.rb
796
+
797
+ | Method | Purpose |
798
+ |---|---|
799
+ | `normalize_social_agent_name` | Validate agent name against regex. |
800
+ | `normalize_social_conversation_name` | Validate conversation name. |
801
+ | `normalize_social_inherit` | Validate inherit mode. |
802
+ | `social_chat_key` | Build `"agent/conversation"` key. |
803
+ | `social_agent_options` | Merge + strip private options for specialist. |
804
+ | `social_duplicate` | Recursive deep-copy (Hash/Array/String). |
805
+ | `social_chat_copy` | Deep-copy a Chat array. |
806
+ | `clone_social_agent` | Clone a template agent with isolated state. |
807
+ | `start_social_chat` | Create a new specialist conversation with inherited context. |
808
+ | `social_caller_context` | Extract non-start\_chat messages from current chat. |
809
+ | `social_inherited_context` | Resolve context based on inherit mode. |
810
+ | `social_tool_parameters` | Parse tool-call parameters, handle legacy `chat` arg. |