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
@@ -1,113 +1,155 @@
1
1
  module LLM
2
2
  class Agent
3
3
 
4
+ SOCIAL_INHERIT_MODES = %w[none tools conversation].freeze
5
+ SOCIAL_AGENT_NAME = /\A[a-z_.-]+\z/i
6
+ SOCIAL_CONVERSATION_NAME = /\A[a-z0-9][a-z0-9_.-]*\z/i
7
+ SOCIAL_PRIVATE_OPTIONS = %i[
8
+ agent client current_meta format messages no_ask_override previous_response_id
9
+ process return_messages tool_choice tools
10
+ ].freeze
11
+
4
12
  attr_accessor :society, :chats
5
13
 
6
- def load_agent(agent_name, options)
7
- raise ParameterException,
8
- "Agent name must be a single word optionally including a few puntuation characters" unless agent_name =~ /^[a-z_.-]*$/i
14
+ # Load one immutable template per specialist. Conversations clone this
15
+ # template so that their current chats and start chats remain independent.
16
+ def load_agent(agent_name, options = {})
17
+ agent_name = normalize_social_agent_name(agent_name)
9
18
  @society ||= {}
10
- @society[agent_name] ||= LLM.load_agent agent_name, options.merge(self.other_options)
19
+ @society[agent_name] ||= begin
20
+ agent = LLM.load_agent(agent_name, social_agent_options(options))
21
+ agent
22
+ end
11
23
  end
12
24
 
13
- def load_chat(agent_name, options, chat)
25
+ # Return a persistent specialist conversation. Conversation identifiers are
26
+ # scoped by specialist, so Worker/work_A and Critic/work_A cannot collide.
27
+ # `inherit` is only used when the conversation is first created.
28
+ def load_chat(agent_name, options = {}, conversation = nil, inherit: 'tools')
29
+ agent_name = normalize_social_agent_name(agent_name)
30
+ conversation = normalize_social_conversation_name(conversation)
31
+ inherit = normalize_social_inherit(inherit)
32
+
14
33
  @chats ||= {}
15
- @chats[chat] ||= load_agent(agent_name, options).clone
34
+ key = social_chat_key(agent_name, conversation)
35
+ @chats[key] ||= begin
36
+ agent = start_social_chat(agent_name, options, inherit)
37
+ agent.save_file = society_save_file(agent_name, conversation)
38
+ agent
39
+ end
40
+
41
+ end
42
+
43
+ # Ask a specialist using a plain-text prompt.
44
+ #
45
+ # With no conversation identifier this is a one-shot call. With an
46
+ # identifier, later calls continue the same specialist instance. The
47
+ # inheritance policy seeds a new call or conversation after the
48
+ # specialist's own start_chat:
49
+ #
50
+ # - none: no context from the caller
51
+ # - tools: only declarative tooling from the caller's task chat
52
+ # - conversation: the caller's complete task chat
53
+ #
54
+ # ScoutCoder: Agent#prompt parses String input as Scout chat-file syntax.
55
+ # Delegated prompts must instead be appended with Agent#user so role-looking
56
+ # text such as "tool:" cannot turn into a control message or grant tools.
57
+ def ask_agent(agent_name, prompt, conversation: nil, inherit: 'tools', options: {})
58
+ raise ParameterException, 'The delegated prompt must be a String' unless String === prompt
59
+
60
+ agent_name = normalize_social_agent_name(agent_name)
61
+ inherit = normalize_social_inherit(inherit)
62
+
63
+ agent = if conversation.nil?
64
+ load_chat(agent_name, options, 'default', inherit: inherit)
65
+ else
66
+ conversation = normalize_social_conversation_name(conversation)
67
+ load_chat(agent_name, options, conversation, inherit: inherit)
68
+ end
69
+
70
+ agent.user(prompt)
71
+
72
+ agent
16
73
  end
17
74
 
75
+ # Expose a deliberately narrow `ask` tool to this agent. The model can send
76
+ # only a specialist name, a plain-text prompt, an optional persistent
77
+ # conversation identifier, and an inheritance policy. It never receives or
78
+ # edits the specialist's Chat object.
18
79
  def socialize(options = {})
19
80
  @other_options[:tools] ||= {}
20
81
  @society ||= {}
82
+ social_options = social_duplicate(options || {})
21
83
 
22
84
  task_name = :ask
23
- block ||= Proc.new do |name, parameters|
24
- agent_name, prompt, chat_id = IndiferentHash.process_options parameters.dup,
25
- :agent, :prompt, :chat,
26
- chat: 'current'
27
-
85
+ block = Proc.new do |_name, parameters|
28
86
  begin
29
- options = options.dup
30
-
31
- res = case chat_id
32
- when 'current'
33
- agent = load_chat agent_name, options, 'current'
34
- chat = self.current_chat - self.start_chat
35
- agent.concat chat
36
- agent.user prompt
37
- agent.chat
38
- when 'none', nil, 'false'
39
- agent = load_agent agent_name, options
40
- agent.prompt prompt
41
- else
42
- agent = load_chat agent_name, options, chat_id
43
- agent.user prompt
44
- agent.chat
45
- end
46
- res
47
- rescue ScoutException
48
- next $!
87
+ agent_name, prompt, conversation, inherit = social_tool_parameters(parameters)
88
+ ask_agent(agent_name, prompt,
89
+ conversation: conversation,
90
+ inherit: inherit,
91
+ options: social_options)
92
+ rescue ScoutException => e
93
+ e
49
94
  end
50
95
  end
51
96
 
52
97
  properties = {
53
98
  agent: {
54
- "type": :string,
55
- "description": "Name of the agent"
99
+ type: 'string',
100
+ description: 'Name of the specialist agent to ask'
56
101
  },
57
102
  prompt: {
58
- "type": :string,
59
- "description": "Prompt to pass to the agent"
103
+ type: 'string',
104
+ description: 'Plain-text prompt sent as one user message to the specialist'
60
105
  },
61
- chat: {
62
- "type": :string,
63
- "description": "(Optional) Chat identifier used to keep conversation history. The default is 'current' uses the conversation that the caller agent is involved with",
64
- "default": 'current'
106
+ conversation: {
107
+ type: 'string',
108
+ pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$',
109
+ description: 'Optional conversation or chat identifier; reuse it with the same agent to continue that conversation across several calls'
110
+ },
111
+ inherit: {
112
+ type: 'string',
113
+ enum: SOCIAL_INHERIT_MODES,
114
+ default: 'tools',
115
+ description: "Context copied only when starting the call or named conversation: 'none' uses only the specialist start_chat, 'tools' also copies caller task tooling, and 'conversation' copies the caller task conversation"
65
116
  }
66
117
  }
67
118
 
68
- required_inputs = [:agent, :prompt]
69
-
70
- description =<<-EOF
71
-
72
- The 'ask' function is used to send a prompt to an agent, returning the agents
73
- response. You can keep one-shot questions or keep running conversations with
74
- the same agent by giving the chat an identifier. The chat id 'current' has the
75
- special meaning of passing the entire conversation to the agent, not just the
76
- prompt.
77
-
119
+ description = <<-EOF
120
+ Ask another agent and receive only its text answer. Omit `conversation` for an
121
+ independent one-shot call. Set `conversation` to a name and reuse the same name
122
+ with the same agent for follow-up turns. `inherit` controls only how a new call
123
+ or named conversation is initialized; follow-up turns retain their own history.
124
+ The specialist's own start_chat is always applied first.
78
125
  EOF
79
126
 
80
127
  function = {
81
128
  name: task_name,
82
129
  description: description,
83
130
  parameters: {
84
- type: "object",
131
+ type: 'object',
85
132
  properties: properties,
86
- required: required_inputs
133
+ required: [:agent, :prompt],
134
+ additionalProperties: false
87
135
  }
88
136
  }
89
137
 
90
- definition = IndiferentHash.setup function.merge(type: 'function', function: function)
91
-
92
-
138
+ definition = IndiferentHash.setup(function.merge(type: 'function', function: function))
93
139
  @other_options[:tools][task_name] = [block, definition]
94
140
  end
95
- end
96
- def delegate(agent, name, description, &block)
141
+
142
+ def delegate(agent, name, description, task_name = nil, &block)
97
143
  @other_options[:tools] ||= {}
98
- task_name = "hand_off_to_#{name}".to_sym
144
+ task_name = "hand_off_to_#{name}".to_sym if task_name.nil?
99
145
 
100
- block ||= Proc.new do |name, parameters|
146
+ block ||= Proc.new do |_name, parameters|
101
147
  message = parameters[:message]
102
148
  new_conversation = parameters[:new_conversation]
103
149
  Log.medium "Delegated to #{agent}: " + Log.fingerprint(message)
104
- if new_conversation
105
- agent.start
106
- else
107
- agent.purge
108
- end
150
+ agent.start if new_conversation
109
151
  agent.user message
110
- agent.chat
152
+ agent
111
153
  end
112
154
 
113
155
  properties = {
@@ -136,8 +178,175 @@ prompt.
136
178
 
137
179
  definition = IndiferentHash.setup function.merge(type: 'function', function: function)
138
180
 
139
-
140
181
  @other_options[:tools][task_name] = [block, definition]
141
182
  end
142
183
 
184
+ private
185
+
186
+ def normalize_social_agent_name(agent_name)
187
+ agent_name = agent_name.to_s if Symbol === agent_name
188
+ unless String === agent_name && SOCIAL_AGENT_NAME.match?(agent_name)
189
+ raise ParameterException,
190
+ 'Agent name must contain only letters, dots, underscores, or hyphens'
191
+ end
192
+ agent_name
193
+ end
194
+
195
+ def normalize_social_conversation_name(conversation)
196
+ conversation = conversation.to_s if Symbol === conversation
197
+ unless String === conversation && SOCIAL_CONVERSATION_NAME.match?(conversation)
198
+ raise ParameterException,
199
+ 'Conversation identifier must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens'
200
+ end
201
+ conversation
202
+ end
203
+
204
+ def normalize_social_inherit(inherit)
205
+ inherit = inherit.to_s
206
+ return inherit if SOCIAL_INHERIT_MODES.include?(inherit)
207
+
208
+ raise ParameterException,
209
+ "Unknown inheritance policy #{inherit.inspect}; expected one of #{SOCIAL_INHERIT_MODES * ', '}"
210
+ end
211
+
212
+ def social_chat_key(agent_name, conversation)
213
+ "#{agent_name}/#{conversation}"
214
+ end
215
+
216
+ def society_save_file(agent_name, conversation)
217
+ # ScoutCoder: the society directory is derived with the CANONICAL rule
218
+ # (Agent.society_dir_for / Agent#society_dir), never by string
219
+ # substitution here. An earlier version did
220
+ # `save_file.sub(/\.chat/, '.society')`: the sub had no anchor, so it
221
+ # replaced the FIRST '.chat' occurrence and a CLI-shaped save_file like
222
+ # `cli.chat.files/agent.chat` turned into
223
+ # `cli.society.files/agent.chat/...`, growing a second `.files` tree
224
+ # instead of `cli.chat.files/agent.society/...`.
225
+ # society_dir_for is the single source of truth for the root
226
+ # (name-derived) rule; society_dir adds the location rule for nested
227
+ # chats.
228
+ #
229
+ # save.rb simply does not auto-save without a save_file; mirror that
230
+ # here instead of raising on nil (a society conversation started by an
231
+ # agent with no save_file just never persists).
232
+ return nil if save_file.nil?
233
+
234
+ File.join(society_dir(save_file), agent_name.to_s, conversation.to_s, SOCIETY_CHAT_FILE)
235
+ end
236
+
237
+ # Provider session state and executable Ruby tool objects belong to the
238
+ # caller. Model/backend defaults may flow to specialists, but capabilities
239
+ # are inherited only through the specialist start_chat or an explicit
240
+ # declarative inheritance policy.
241
+ def social_agent_options(options)
242
+ defaults = IndiferentHash.setup(social_duplicate(other_options || {}))
243
+ supplied = IndiferentHash.setup(social_duplicate(options || {}))
244
+ merged = defaults.merge(supplied)
245
+ SOCIAL_PRIVATE_OPTIONS.each { |name| merged.delete(name) }
246
+ merged
247
+ end
248
+
249
+ def social_duplicate(value)
250
+ case value
251
+ when Hash
252
+ value.each_with_object({}) do |(key, item), copy|
253
+ copy[social_duplicate(key)] = social_duplicate(item)
254
+ end
255
+ when Array
256
+ value.collect { |item| social_duplicate(item) }
257
+ when String
258
+ value.dup
259
+ else
260
+ value
261
+ end
262
+ end
263
+
264
+ def social_chat_copy(chat)
265
+ Chat.setup(social_duplicate(chat || []))
266
+ end
267
+
268
+ def clone_social_agent(template)
269
+ agent = template.clone
270
+ agent.start_chat = social_chat_copy(template.start_chat)
271
+ agent.other_options = IndiferentHash.setup(social_duplicate(template.other_options || {}))
272
+ agent.society = nil
273
+ agent.chats = nil
274
+ agent.instance_variable_set(:@current_chat, nil)
275
+ agent
276
+ end
277
+
278
+ def start_social_chat(agent_name, options, inherit)
279
+ template = load_agent(agent_name, options)
280
+ agent = clone_social_agent(template)
281
+ initial_chat = social_chat_copy(agent.start_chat)
282
+ initial_chat.follow(social_inherited_context(inherit))
283
+ agent.start(initial_chat)
284
+ agent
285
+ end
286
+
287
+ # In the usual Agent#start branch, start-chat messages are the same Hash
288
+ # objects in both arrays, which lets us remove the caller agent's policy
289
+ # exactly even if Agent#ask has removed control roles. The prefix fallback
290
+ # covers callers that adopted an equivalent, separately parsed Chat.
291
+ def social_caller_context
292
+ current = current_chat || []
293
+ base = start_chat || []
294
+ base_ids = base.each_with_object({}) { |message, ids| ids[message.object_id] = true }
295
+
296
+ context = if current.any? { |message| base_ids[message.object_id] }
297
+ current.reject { |message| base_ids[message.object_id] }
298
+ else
299
+ prefix = 0
300
+ limit = [current.length, base.length].min
301
+ prefix += 1 while prefix < limit && current[prefix] == base[prefix]
302
+ current.drop(prefix)
303
+ end
304
+
305
+ social_chat_copy(context)
306
+ end
307
+
308
+ def social_inherited_context(inherit)
309
+ case inherit
310
+ when 'none'
311
+ Chat.setup([])
312
+ when 'tools'
313
+ #tooling = social_caller_context.tooling
314
+ tooling = self.current_chat.tooling
315
+ social_chat_copy(tooling)
316
+ when 'conversation'
317
+ social_caller_context
318
+ end
319
+ end
320
+
321
+ # Accept the former `chat` argument when a stored tool call is replayed, but
322
+ # do not advertise it in the schema. New calls keep conversation identity
323
+ # and inheritance as independent concepts.
324
+ def social_tool_parameters(parameters)
325
+ parameters = IndiferentHash.setup((parameters || {}).dup)
326
+ agent_name = parameters[:agent]
327
+ prompt = parameters[:prompt]
328
+ conversation = parameters[:conversation]
329
+ inherit = parameters[:inherit]
330
+
331
+ if parameters.include?(:chat)
332
+ raise ParameterException, 'Use either conversation or the legacy chat argument, not both' if conversation
333
+
334
+ legacy_chat = parameters[:chat]
335
+ case legacy_chat.to_s
336
+ when 'current'
337
+ conversation = 'current'
338
+ inherit ||= 'conversation'
339
+ when '', 'none', 'false'
340
+ conversation = nil
341
+ inherit ||= 'none'
342
+ else
343
+ conversation = legacy_chat
344
+ inherit ||= 'tools'
345
+ end
346
+ end
347
+
348
+ inherit ||= 'tools'
349
+ [agent_name, prompt, conversation, inherit]
350
+ end
351
+ end
143
352
  end
@@ -25,7 +25,7 @@ module LLM
25
25
  list.each &block
26
26
  end
27
27
 
28
- def iterate_dictionary(prompt = nil, &block)
28
+ def iterate_dictionary(prompt = nil, **kwargs, &block)
29
29
  self.endpoint :responses
30
30
  self.user prompt if prompt
31
31
 
@@ -38,7 +38,7 @@ module LLM
38
38
 
39
39
  self.option :format, :text
40
40
 
41
- dict.each &block
41
+ TSV.traverse dict, **kwargs, &block
42
42
  end
43
43
  end
44
44
  end
@@ -0,0 +1,273 @@
1
+ require 'set'
2
+
3
+ module LLM
4
+ class Agent
5
+ # Directory holding the saved conversations of every nested specialist
6
+ # (the "society") reachable from this agent.
7
+ SOCIETY_DIR = 'society'.freeze
8
+
9
+ # File name used for every saved conversation inside the society tree.
10
+ SOCIETY_CHAT_FILE = 'agent.chat'.freeze
11
+
12
+ # Hard recursion cap while saving nested societies. Object cycles are
13
+ # already cut by the visited sets; the cap is belt and braces against
14
+ # pathological trees that keep minting fresh agents and paths.
15
+ SAVE_DEPTH_LIMIT = 32
16
+
17
+ # Conservative fallback for society key parts that do not match the
18
+ # socialization name patterns. No dots, so '.'/'..' can never survive.
19
+ SAVE_SANITIZER = /[^a-zA-Z0-9_-]/
20
+
21
+ # Chat file this agent appends to / auto-saves to. Setting it makes `save`
22
+ # work with no arguments and makes every successful chat round auto-save
23
+ # this agent's full state (chat + nested society) there.
24
+ attr_accessor :save_file
25
+
26
+ class << self
27
+ # Society directory of a ROOT chat saved at `chat_path`:
28
+ # `.../<name>.society` for a chat file `.../<name>.chat`
29
+ # (`agent.chat` -> `agent.society`; `worker.chat` -> `worker.society`).
30
+ #
31
+ # ScoutCoder: this is the NAME-DERIVED rule, used at depth 0 only. A
32
+ # path that does not end in `.chat` is normalized by dropping its last
33
+ # component (defensive: `society_dir_for(dir)` still yields a directory
34
+ # sibling, never `<dir>.files/...`); both Path and String inputs are
35
+ # accepted and the result is always a plain String.
36
+ #
37
+ # This is the ROOT rule only. The general rule (Agent#society_dir) is
38
+ # decided from the location of the chat being written, so a nested
39
+ # agent.chat never grows a second `.files` tree of its own:
40
+ #
41
+ # chat.chat
42
+ # chat.chat.society/<agent>/<conversation>/agent.chat
43
+ # chat.chat.society/<agent>/<conversation>/society/<agent>/<conversation>/agent.chat
44
+ def society_dir_for(chat_path)
45
+ p = chat_path.to_s
46
+ p = File.dirname(p) unless p =~ /\.chat\z/
47
+ File.join(File.dirname(p), File.basename(p).sub(/\.chat\z/, '.society'))
48
+ end
49
+
50
+ # ScoutCoder: LEGACY root society directory (`<path>.files/log/society`)
51
+ # from before the flat layout change. Kept read-only: nothing writes
52
+ # here anymore, but provenance traversal must still glob it so chats
53
+ # saved by older versions remain visible (see
54
+ # Chat.direct_chat_sidecar_files).
55
+ def legacy_society_dir_for(chat_path)
56
+ "#{chat_path}.files/log/#{SOCIETY_DIR}"
57
+ end
58
+
59
+ # Is `path` the chat file of a NESTED conversation, i.e. does it already
60
+ # live inside a society tree? A nested chat file sits at
61
+ # <society>/<agent>/<conversation>/<file>, so the directory three levels
62
+ # up from the file is the society directory itself.
63
+ # (Two levels up would be <agent>, one level up <conversation>.)
64
+ #
65
+ # The society directory of a depth-0 chat is named after it
66
+ # (<name>.society), while every deeper level keeps the plain 'society'
67
+ # sibling dir, so BOTH basenames count here.
68
+ def nested_save?(path)
69
+ parent = File.basename(File.dirname(File.dirname(File.dirname(path.to_s))))
70
+ parent == SOCIETY_DIR || parent.end_with?('.' + SOCIETY_DIR)
71
+ end
72
+ end
73
+
74
+ def society_dir_for(chat_path)
75
+ self.class.society_dir_for(chat_path)
76
+ end
77
+
78
+ # Society directory for the chat written at `path`, whatever the entry
79
+ # point was (root save, recursion, or a later standalone auto-save of a
80
+ # child that had its save_file assigned by a parent):
81
+ #
82
+ # - root chat (`.../conversation.chat`): sibling `<name>.society`
83
+ # - nested chat (`.../society/<a>/<c>/<file>`): sibling `<dirname>/society`
84
+ #
85
+ # Deciding by LOCATION (not by recursion depth) is what keeps the layout
86
+ # canonical: a nested agent.chat never produces `<nested>.files/...`, no
87
+ # matter who triggered the save.
88
+ def society_dir(path)
89
+ if self.class.nested_save?(path)
90
+ File.join(File.dirname(path), SOCIETY_DIR)
91
+ else
92
+ self.class.society_dir_for(path)
93
+ end
94
+ end
95
+
96
+ def nested_save?(path)
97
+ self.class.nested_save?(path)
98
+ end
99
+
100
+ # Save the current conversation of this agent and of every nested
101
+ # specialist conversation (`chats`) reachable from it.
102
+ #
103
+ # `path` defaults to the configured `save_file`; when both are nil a
104
+ # ScoutException is raised explaining how to configure the target.
105
+ #
106
+ # Serialization: the FULL current_chat is written, not the delta against
107
+ # start_chat. These files are recovery/restart artifacts, so the saved
108
+ # conversation must be complete. AgentWorkflow#chat_task keeps its own
109
+ # delta semantics (result = current_chat - start_chat) for job results.
110
+ #
111
+ # Layout: this agent's chat is written at `path`; each society entry is
112
+ # written at `<society_dir(path)>/<agent_name>/<conversation>/agent.chat`,
113
+ # where society_dir is computed from the LOCATION of `path` (see
114
+ # Agent#society_dir), so nested societies always sit next to their
115
+ # agent.chat and the tree nests one level deeper each time.
116
+ #
117
+ # Children are saved by recursing into `child.save(child_path, ...)`, and
118
+ # each child's `save_file` is assigned so a later independent ask by that
119
+ # child auto-saves to exactly the same place.
120
+ #
121
+ # Cycle safety: `visited` (resolved target paths) and `seen_agents`
122
+ # (agent objects) are threaded through the recursion; a target path or an
123
+ # agent already saved by this run is skipped with a debug log, and
124
+ # SAVE_DEPTH_LIMIT stops (with a warning, not an exception) anything that
125
+ # still runs away.
126
+ #
127
+ # Lazy: nothing is created eagerly. Open.sensible_write makes parent
128
+ # directories on demand, so an agent with no live society writes ONLY its
129
+ # chat file and no `.files` tree appears.
130
+ #
131
+ # Returns the sorted Array of absolute paths whose content is now on disk
132
+ # and current, whether this call just wrote them or found them already up
133
+ # to date (an unchanged file is not rewritten but IS reported, because the
134
+ # useful contract is "these files now hold this state").
135
+ def save(path = nil, visited: nil, seen_agents: nil, depth: 0)
136
+ path ||= save_file
137
+ raise ScoutException,
138
+ "No save file for agent. Pass a path or configure agent.save_file = <chat file>" if path.nil?
139
+
140
+ path = path.to_s
141
+ visited ||= Set.new
142
+ seen_agents ||= Set.new
143
+ resolved = File.expand_path(path)
144
+
145
+ if depth > SAVE_DEPTH_LIMIT
146
+ Log.warn "Agent save deeper than #{SAVE_DEPTH_LIMIT} levels under #{resolved}; stopping recursion"
147
+ return []
148
+ end
149
+
150
+ if visited.include?(resolved)
151
+ Log.debug "Agent save: conversation already saved at #{resolved}"
152
+ return []
153
+ end
154
+ if seen_agents.include?(self)
155
+ Log.debug "Agent save: agent already saved in this run, skipping #{resolved}"
156
+ return []
157
+ end
158
+
159
+ # current_chat is normally never nil (it lazily builds from start_chat),
160
+ # but be explicit: an agent asked to save before any conversation exists
161
+ # saves its (possibly empty) start-based chat rather than crashing.
162
+ chat = current_chat || start
163
+ content = chat.print
164
+
165
+ # Recovery artifact: always complete, so refresh it when it changed
166
+ # (Open.sensible_write alone would keep the first version forever).
167
+ if Open.exist?(path) && Open.read(path) == content
168
+ Log.debug "Agent save: #{resolved} is up to date"
169
+ else
170
+ Open.sensible_write(path, content, force: true)
171
+ end
172
+
173
+ visited << resolved
174
+ seen_agents << self
175
+ written = []
176
+
177
+ # Report only files that verifiably hold the expected content.
178
+ written << resolved if Open.exist?(path)
179
+ save_society(path, visited, seen_agents, depth, written)
180
+
181
+ written.sort
182
+ end
183
+
184
+ # Auto-save after a turn that changed this agent's conversation. Only
185
+ # fires on agents with a configured save_file and is never fatal (a save
186
+ # failure is logged and the agent run continues). It is a full recursive
187
+ # save, so the tree stays complete after any turn anywhere in it.
188
+ def save_if_configured
189
+ return if save_file.nil?
190
+ save
191
+ end
192
+
193
+ # Snapshot the conversation as it stood before a restart, so a restarted
194
+ # conversation can still be recovered. Writes only when there is a prior,
195
+ # non-empty current_chat (lazy: no empty resets), and never lets a save
196
+ # problem break the restart. Timestamp collisions (two restarts within
197
+ # the same millisecond) get a _1, _2, ... suffix instead of clobbering.
198
+ def save_restart_snapshot
199
+ return if save_file.nil?
200
+ prior = @current_chat
201
+ return unless Chat === prior && prior.any?
202
+
203
+ dir = File.join("#{save_file}.files", 'resets')
204
+ # Colons are legal on unix but hostile to portability and tooling
205
+ base = Chat.timestamp.gsub(':', '')
206
+ file = File.join(dir, "#{base}.chat")
207
+ suffix = 0
208
+ while Open.exist?(file)
209
+ suffix += 1
210
+ file = File.join(dir, "#{base}_#{suffix}.chat")
211
+ end
212
+ Open.sensible_write(file, prior.print)
213
+ nil
214
+ end
215
+
216
+ private
217
+
218
+ # Save every live nested conversation of this agent under its society
219
+ # directory. Non-Agent values are ignored; keys are expected to look like
220
+ # `agent_name/conversation` but are sanitized defensively so a malformed
221
+ # key can never escape the society directory.
222
+ def save_society(path, visited, seen_agents, depth, written)
223
+ chats = @chats
224
+ return unless Hash === chats && chats.any?
225
+
226
+ society_dir = society_dir(path)
227
+
228
+ # Keys are normally Strings but nothing forbids Symbols (or mixed
229
+ # hashes); coerce to String so the sort can never raise a TypeError.
230
+ chats.sort_by { |key, _agent| key.to_s }.each do |key, agent|
231
+ next unless LLM::Agent === agent
232
+
233
+ agent_name, conversation = save_split_social_key(key)
234
+ next if agent_name.nil? || conversation.nil?
235
+
236
+ child_path = File.join(society_dir, agent_name, conversation, SOCIETY_CHAT_FILE)
237
+ next if Open.exists?(child_path) && Path.newer?(child_path, save_file)
238
+ # The child remembers its own file so a later independent ask by that
239
+ # child auto-saves in place instead of needing the parent save.
240
+ agent.save_file = child_path
241
+ written.concat(agent.save(child_path, visited: visited,
242
+ seen_agents: seen_agents,
243
+ depth: depth + 1))
244
+ end
245
+ end
246
+
247
+ # Split an `agent_name/conversation` society key into its sanitized parts.
248
+ # Both parts must be non-empty; names matching the socialization regexes
249
+ # are kept verbatim, anything else falls back to a conservative sanitizer
250
+ # (path-traversal characters replaced by '_').
251
+ def save_split_social_key(key)
252
+ key = key.to_s
253
+ return [nil, nil] unless key.include?('/')
254
+
255
+ raw_agent, raw_conversation = key.split('/', 2)
256
+ return [nil, nil] if raw_agent.to_s.empty? || raw_conversation.to_s.empty?
257
+
258
+ [
259
+ save_sanitize_part(raw_agent, SOCIAL_AGENT_NAME),
260
+ save_sanitize_part(raw_conversation, SOCIAL_CONVERSATION_NAME)
261
+ ]
262
+ end
263
+
264
+ def save_sanitize_part(part, pattern)
265
+ part = part.to_s
266
+ return part if !part.empty? && !part.include?('/') &&
267
+ part != '.' && part != '..' && pattern.match?(part)
268
+
269
+ sanitized = part.gsub(SAVE_SANITIZER, '_')
270
+ sanitized.empty? ? '_' : sanitized
271
+ end
272
+ end
273
+ end