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.
- checksums.yaml +4 -4
- data/.vimproject +138 -50
- data/README.md +171 -290
- data/Rakefile +17 -1
- data/VERSION +1 -1
- data/doc/Improvements.md +325 -0
- data/doc/StartHere.md +110 -0
- data/doc/developer/Architecture.md +126 -0
- data/doc/developer/Backends.md +199 -0
- data/doc/developer/ChatLifecycle.md +183 -0
- data/doc/developer/DelegationInternals.md +295 -0
- data/doc/developer/DesignPrinciples.md +245 -0
- data/doc/developer/PromptProcessing.md +292 -0
- data/doc/developer/Provenance.md +317 -0
- data/doc/user/BuildingAgents.md +345 -0
- data/doc/user/Cookbook.md +333 -0
- data/doc/user/CoreConcepts.md +181 -0
- data/doc/user/Delegation.md +191 -0
- data/doc/user/GettingStarted.md +159 -0
- data/doc/user/ManagingContext.md +163 -0
- data/doc/user/MultiAgentWorkflows.md +256 -0
- data/doc/user/Python.md +159 -0
- data/doc/user/RunningInference.md +200 -0
- data/doc/user/ToolCalling.md +193 -0
- data/doc/user/WritingChats.md +197 -0
- data/lib/scout/llm/agent/chat.rb +61 -11
- data/lib/scout/llm/agent/delegate.rb +274 -65
- data/lib/scout/llm/agent/iterate.rb +2 -2
- data/lib/scout/llm/agent/save.rb +273 -0
- data/lib/scout/llm/agent/workflow.rb +164 -0
- data/lib/scout/llm/agent.rb +86 -61
- data/lib/scout/llm/ask.rb +62 -17
- data/lib/scout/llm/backends/anthropic.rb +9 -2
- data/lib/scout/llm/backends/bedrock.rb +15 -3
- data/lib/scout/llm/backends/default.rb +183 -99
- data/lib/scout/llm/backends/glm.rb +58 -0
- data/lib/scout/llm/backends/huggingface.rb +196 -26
- data/lib/scout/llm/backends/ollama.rb +13 -1
- data/lib/scout/llm/backends/openai.rb +0 -2
- data/lib/scout/llm/backends/openwebui.rb +20 -13
- data/lib/scout/llm/backends/relay.rb +22 -22
- data/lib/scout/llm/backends/responses.rb +1 -1
- data/lib/scout/llm/chat/agent_meta.rb +264 -0
- data/lib/scout/llm/chat/annotation.rb +39 -10
- data/lib/scout/llm/chat/parse.rb +28 -6
- data/lib/scout/llm/chat/persist.rb +25 -0
- data/lib/scout/llm/chat/process/clear.rb +41 -6
- data/lib/scout/llm/chat/process/files.rb +21 -6
- data/lib/scout/llm/chat/process/meta.rb +421 -34
- data/lib/scout/llm/chat/process/options.rb +21 -1
- data/lib/scout/llm/chat/process/tools.rb +56 -15
- data/lib/scout/llm/chat/process.rb +4 -0
- data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
- data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
- data/lib/scout/llm/chat/prompt.rb +48 -0
- data/lib/scout/llm/chat/provenance.rb +775 -0
- data/lib/scout/llm/chat/tool_calls.rb +76 -0
- data/lib/scout/llm/chat.rb +18 -2
- data/lib/scout/llm/embed.rb +11 -3
- data/lib/scout/llm/image.rb +86 -0
- data/lib/scout/llm/mcp.rb +10 -2
- data/lib/scout/llm/rag.rb +3 -3
- data/lib/scout/llm/tools/call.rb +160 -11
- data/lib/scout/llm/tools/knowledge_base.rb +1 -1
- data/lib/scout/llm/tools/workflow.rb +32 -16
- data/lib/scout/model/python/huggingface/causal.rb +23 -5
- data/lib/scout/model/python/huggingface.rb +2 -1
- data/lib/scout-ai.rb +1 -0
- data/python/README.md +197 -14
- data/python/scout_ai/huggingface/eval.py +245 -34
- data/python/tests/test_huggingface_eval.py +58 -0
- data/research/ChatAnalyst-required-changes.md +167 -0
- data/research/agent-delegation-analysis.md +810 -0
- data/research/agent-meta-provenance-integration-plan.md +622 -0
- data/research/agent-workflow-analysis.md +1120 -0
- data/research/backends-analysis.md +836 -0
- data/research/chat-core-analysis.md +946 -0
- data/research/chatanalyst-provenance/00-baseline.md +30 -0
- data/research/chatanalyst-provenance/01-repo-map.md +60 -0
- data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
- data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
- data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
- data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
- data/research/chatanalyst-provenance/07-critic-review.md +25 -0
- data/research/chatanalyst-provenance/final-report.md +45 -0
- data/research/chatanalyst-provenance/resumption.md +37 -0
- data/research/coding-philosophy-analysis.md +928 -0
- data/research/commands-analysis.md +947 -0
- data/research/multi-agent-patterns-analysis.md +853 -0
- data/research/prompt-strategies-analysis.md +630 -0
- data/research/prov-verbosity-fix-notes.md +77 -0
- data/research/provenance-analysis.md +469 -0
- data/research/provenance-navigation-design.md +640 -0
- data/research/synthesis-report.md +487 -0
- data/research/tools-system-analysis.md +779 -0
- data/scout-ai.gemspec +100 -11
- data/scout_commands/agent/ask +13 -3
- data/scout_commands/agent/kb +2 -0
- data/scout_commands/llm/ask +11 -4
- data/scout_commands/llm/md +76 -0
- data/scout_commands/llm/process_queries +48 -0
- data/scout_commands/llm/prov +602 -0
- data/scout_commands/llm/word +71 -0
- data/scout_commands/workflow/mcp +43 -0
- data/share/word/reference.docx +0 -0
- data/test/etc/AI/mock.yaml +11 -0
- data/test/fixtures/backends/anthropic.json +19 -0
- data/test/fixtures/backends/anthropic_tool_use.json +24 -0
- data/test/fixtures/backends/bedrock.json +8 -0
- data/test/fixtures/backends/bedrock_embedding.json +3 -0
- data/test/fixtures/backends/bedrock_tool_use.json +17 -0
- data/test/fixtures/backends/ollama.json +16 -0
- data/test/fixtures/backends/ollama_tool_call.json +27 -0
- data/test/fixtures/backends/openai_chat.json +21 -0
- data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
- data/test/fixtures/backends/responses.json +33 -0
- data/test/fixtures/backends/responses_tool_call.json +28 -0
- data/test/integration/README.md +32 -0
- data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
- data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
- data/test/integration/scout/llm/backends/test_relay.rb +52 -0
- data/test/integration/scout/llm/test_infrastructure.rb +74 -0
- data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
- data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
- data/test/integration/scout/model/test_base.rb +91 -0
- data/test/scout/llm/agent/test_chat.rb +8 -2
- data/test/scout/llm/agent/test_save.rb +413 -0
- data/test/scout/llm/agent/test_workflow.rb +110 -0
- data/test/scout/llm/backends/test_anthropic.rb +93 -10
- data/test/scout/llm/backends/test_bedrock.rb +118 -2
- data/test/scout/llm/backends/test_huggingface.rb +137 -42
- data/test/scout/llm/backends/test_ollama.rb +70 -20
- data/test/scout/llm/backends/test_openwebui.rb +42 -40
- data/test/scout/llm/backends/test_relay.rb +4 -2
- data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
- data/test/scout/llm/chat/process/test_meta.rb +518 -0
- data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
- data/test/scout/llm/chat/test_agent_meta.rb +357 -0
- data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
- data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
- data/test/scout/llm/chat/test_parse.rb +70 -15
- data/test/scout/llm/chat/test_prov_cli.rb +274 -0
- data/test/scout/llm/chat/test_provenance.rb +240 -0
- data/test/scout/llm/chat/test_tool_calls.rb +38 -0
- data/test/scout/llm/test_agent.rb +13 -36
- data/test/scout/llm/test_ask.rb +75 -52
- data/test/scout/llm/test_chat.rb +107 -13
- data/test/scout/llm/test_embed.rb +48 -0
- data/test/scout/llm/test_rag.rb +23 -16
- data/test/scout/llm/test_tools.rb +12 -1
- data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
- data/test/scout/llm/tools/test_mcp.rb +5 -3
- data/test/scout/llm/tools/test_workflow.rb +23 -2
- data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
- data/test/scout/model/python/huggingface/test_causal.rb +9 -3
- data/test/scout/model/python/huggingface/test_classification.rb +11 -2
- data/test/scout/model/python/test_torch.rb +2 -0
- data/test/scout/model/python/torch/test_helpers.rb +4 -0
- data/test/scout/model/test_base.rb +4 -2
- data/test/support/availability.rb +231 -0
- data/test/support/fake_clients.rb +138 -0
- data/test/support/fixtures.rb +21 -0
- data/test/support/infrastructure_probes.rb +136 -0
- data/test/support/mock_backend.rb +215 -0
- data/test/test_helper.rb +32 -2
- metadata +99 -10
- data/doc/Agent.md +0 -327
- data/doc/Chat.md +0 -458
- data/doc/LLM.md +0 -340
- data/doc/RAG.md +0 -129
- data/scout_commands/documenter +0 -148
- data/test/scout/llm/backends/test_openai.rb +0 -192
- data/test/scout/llm/backends/test_responses.rb +0 -238
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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] ||=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
55
|
-
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'Name of the specialist agent to ask'
|
|
56
101
|
},
|
|
57
102
|
prompt: {
|
|
58
|
-
|
|
59
|
-
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: 'Plain-text prompt sent as one user message to the specialist'
|
|
60
105
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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:
|
|
131
|
+
type: 'object',
|
|
85
132
|
properties: properties,
|
|
86
|
-
required:
|
|
133
|
+
required: [:agent, :prompt],
|
|
134
|
+
additionalProperties: false
|
|
87
135
|
}
|
|
88
136
|
}
|
|
89
137
|
|
|
90
|
-
definition = IndiferentHash.setup
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|