Class: LLM::Agent
- Inherits:
-
Object
- Object
- LLM::Agent
- Defined in:
- lib/scout/llm/agent.rb,
lib/scout/llm/agent/chat.rb,
lib/scout/llm/agent/save.rb,
lib/scout/llm/agent/iterate.rb,
lib/scout/llm/agent/delegate.rb,
lib/scout/llm/agent/workflow.rb
Constant Summary collapse
- SOCIETY_DIR =
Directory holding the saved conversations of every nested specialist (the "society") reachable from this agent.
'society'.freeze
- SOCIETY_CHAT_FILE =
File name used for every saved conversation inside the society tree.
'agent.chat'.freeze
- SAVE_DEPTH_LIMIT =
Hard recursion cap while saving nested societies. Object cycles are already cut by the visited sets; the cap is belt and braces against pathological trees that keep minting fresh agents and paths.
32- SAVE_SANITIZER =
Conservative fallback for society key parts that do not match the socialization name patterns. No dots, so '.'/'..' can never survive.
/[^a-zA-Z0-9_-]/- SOCIAL_INHERIT_MODES =
%w[none tools conversation].freeze
- SOCIAL_AGENT_NAME =
/\A[a-z_.-]+\z/i- SOCIAL_CONVERSATION_NAME =
/\A[a-z0-9][a-z0-9_.-]*\z/i- SOCIAL_PRIVATE_OPTIONS =
i[ agent client format no_ask_override previous_response_id process tool_choice tools ].freeze
Instance Attribute Summary collapse
-
#chats ⇒ Object
Returns the value of attribute chats.
-
#job ⇒ Object
Returns the value of attribute job.
-
#knowledge_base ⇒ Object
Returns the value of attribute knowledge_base.
-
#other_options ⇒ Object
Returns the value of attribute other_options.
-
#path ⇒ Object
Returns the value of attribute path.
-
#process_exception ⇒ Object
Returns the value of attribute process_exception.
-
#save_file ⇒ Object
Chat file this agent appends to / auto-saves to.
-
#society ⇒ Object
Returns the value of attribute society.
-
#start_chat ⇒ Object
Returns the value of attribute start_chat.
-
#workflow(&block) ⇒ Object
Returns the value of attribute workflow.
Class Method Summary collapse
-
.legacy_society_dir_for(chat_path) ⇒ Object
ScoutCoder: LEGACY root society directory (
<path>.files/log/society) from before the flat layout change. - .load_agent(agent_name = nil, options = {}) ⇒ Object
- .load_from_path(path, workflow: nil, knowledge_base: nil, chat: nil) ⇒ Object
-
.nested_save?(path) ⇒ Boolean
Is
paththe chat file of a NESTED conversation, i.e. -
.society_dir_for(chat_path) ⇒ Object
Society directory of a ROOT chat saved at
chat_path:.../<name>.societyfor a chat file.../<name>.chat(agent.chat->agent.society;worker.chat->worker.society).
Instance Method Summary collapse
-
#ask(messages = nil, options = {}) ⇒ Object
function: takes an array of messages and calls LLM.ask with them.
-
#ask_agent(agent_name, prompt, conversation: nil, inherit: 'tools', options: {}) ⇒ Object
Ask a specialist using a plain-text prompt.
- #chat(options = {}) ⇒ Object
- #create_image(file, options = {}) ⇒ Object
- #current_chat ⇒ Object
- #delegate(agent, name, description, task_name = nil, &block) ⇒ Object
- #format_message(message, prefix = "user") ⇒ Object
-
#get_previous_response_id ⇒ Object
def json_format(format, options = {}) current_chat.format format output = ask(current_chat, options.merge(false)) current_chat.format nil obj = begin obj = Chat.parse_json output rescue JSON::ParserError Log.warn "Not valid JSON:" + output raise $! end if (Hash === obj) and obj.keys == ['content'] obj else obj end end.
-
#initialize(workflow: nil, knowledge_base: nil, start_chat: nil, **kwargs) ⇒ Agent
constructor
A new instance of Agent.
- #iterate(prompt = nil, &block) ⇒ Object
- #iterate_dictionary(prompt = nil, **kwargs, &block) ⇒ Object
- #json ⇒ Object
- #json_format(format) ⇒ Object
-
#load_agent(agent_name, options = {}) ⇒ Object
Load one immutable template per specialist.
-
#load_chat(agent_name, options = {}, conversation = nil, inherit: 'tools') ⇒ Object
Return a persistent specialist conversation.
- #method_missing(name) ⇒ Object
- #nested_save?(path) ⇒ Boolean
- #prompt(messages, options = {}) ⇒ Object
- #respond ⇒ Object
-
#save(path = nil, visited: nil, seen_agents: nil, depth: 0) ⇒ Object
Save the current conversation of this agent and of every nested specialist conversation (
chats) reachable from it. -
#save_if_configured ⇒ Object
Auto-save after a turn that changed this agent's conversation.
-
#save_restart_snapshot ⇒ Object
Snapshot the conversation as it stood before a restart, so a restarted conversation can still be recovered.
-
#socialize(options = {}) ⇒ Object
Expose a deliberately narrow
asktool to this agent. -
#society_dir(path) ⇒ Object
Society directory for the chat written at
path, whatever the entry point was (root save, recursion, or a later standalone auto-save of a child that had its save_file assigned by a parent):. - #society_dir_for(chat_path) ⇒ Object
- #start(chat = nil) ⇒ Object
Constructor Details
#initialize(workflow: nil, knowledge_base: nil, start_chat: nil, **kwargs) ⇒ Agent
Returns a new instance of Agent.
15 16 17 18 19 20 21 |
# File 'lib/scout/llm/agent.rb', line 15 def initialize(workflow: nil, knowledge_base: nil, start_chat: nil, **kwargs) @workflow = workflow @workflow = Workflow.require_workflow @workflow if String === @workflow @knowledge_base = knowledge_base = IndiferentHash.setup(kwargs.dup) @start_chat = start_chat end |
Dynamic Method Handling
This class handles dynamic methods through the method_missing method
#method_missing(name) ⇒ Object
31 32 33 |
# File 'lib/scout/llm/agent/chat.rb', line 31 def method_missing(name,...) current_chat.send(name, ...) end |
Instance Attribute Details
#chats ⇒ Object
Returns the value of attribute chats.
12 13 14 |
# File 'lib/scout/llm/agent/delegate.rb', line 12 def chats @chats end |
#job ⇒ Object
Returns the value of attribute job.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def job @job end |
#knowledge_base ⇒ Object
Returns the value of attribute knowledge_base.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def knowledge_base @knowledge_base end |
#other_options ⇒ Object
Returns the value of attribute other_options.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def end |
#path ⇒ Object
Returns the value of attribute path.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def path @path end |
#process_exception ⇒ Object
Returns the value of attribute process_exception.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def process_exception @process_exception end |
#save_file ⇒ Object
Chat file this agent appends to / auto-saves to. Setting it makes save
work with no arguments and makes every successful chat round auto-save
this agent's full state (chat + nested society) there.
24 25 26 |
# File 'lib/scout/llm/agent/save.rb', line 24 def save_file @save_file end |
#society ⇒ Object
Returns the value of attribute society.
12 13 14 |
# File 'lib/scout/llm/agent/delegate.rb', line 12 def society @society end |
#start_chat ⇒ Object
Returns the value of attribute start_chat.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def start_chat @start_chat end |
#workflow(&block) ⇒ Object
Returns the value of attribute workflow.
13 14 15 |
# File 'lib/scout/llm/agent.rb', line 13 def workflow @workflow end |
Class Method Details
.legacy_society_dir_for(chat_path) ⇒ Object
ScoutCoder: LEGACY root society directory (<path>.files/log/society)
from before the flat layout change. Kept read-only: nothing writes
here anymore, but provenance traversal must still glob it so chats
saved by older versions remain visible (see
Chat.direct_chat_sidecar_files).
55 56 57 |
# File 'lib/scout/llm/agent/save.rb', line 55 def legacy_society_dir_for(chat_path) "#{chat_path}.files/log/#{SOCIETY_DIR}" end |
.load_agent(agent_name = nil, options = {}) ⇒ Object
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/scout/llm/agent.rb', line 161 def self.load_agent(agent_name = nil, = {}) agent_name = agent_name.to_s if Symbol === agent_name if agent_name && Path.is_filename?(agent_name) if File.directory?(agent_name) dir = Path.setup(agent_name) unless Path === agent_name if dir.agent.find_with_extension("rb").exists? return load dir.agent.find_with_extension("rb") end else return load agent_name end end if agent_name workflow_path = Scout.workflows[agent_name] agent_path = Scout.Agent[agent_name] agent_path = Scout.var.Agent[agent_name] unless agent_path.exists? agent_path = Scout.chats.Agent[agent_name] unless agent_path.exists? agent_path = Scout.chats[agent_name] unless agent_path.exists? raise ScoutException, "No agent found with name #{agent_name}" unless workflow_path.exists? || agent_path.exists? @@agent_workflow ||= {} workflow = @@agent_workflow[agent_name] ||= if workflow_path.exists? agent_path = workflow_path Workflow.require_workflow agent_name elsif agent_path.workflow.find_with_extension("rb").exists? Workflow.require_workflow_file agent_path.workflow.find_with_extension("rb") elsif agent_path.python.exists? && agent_path.python.glob('*.py').any? require 'scout/workflow/python' PythonWorkflow.load_directory agent_path.python, 'ScoutAgent' end knowledge_base = if agent_path.knowledge_base.exists? KnowledgeBase.load agent_path.knowledge_base.find elsif workflow_path.knowledge_base.exists? KnowledgeBase.load workflow_path.knowledge_base.find end chat = if agent_path.start_chat.exists? Chat.setup LLM.chat(agent_path.start_chat.find) elsif workflow_path.start_chat.exists? Chat.setup LLM.chat(workflow_path.start_chat.find) elsif agent_path.start_chat.exists? Chat.setup LLM.chat(agent_path.start_chat.find) elsif workflow && workflow.documentation[:description] Chat.setup([ {role: 'introduce', content: workflow.name} ]) end end agent = LLM::Agent.new **.merge(workflow: workflow, knowledge_base: knowledge_base, start_chat: chat) agent.path = agent_path.find if agent_path agent end |
.load_from_path(path, workflow: nil, knowledge_base: nil, chat: nil) ⇒ Object
149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/scout/llm/agent.rb', line 149 def self.load_from_path(path, workflow: nil, knowledge_base: nil, chat: nil) workflow_path = path['workflow.rb'].find knowledge_base_path = path['knowledge_base'] chat_path = path['start_chat'] workflow ||= Workflow.require_workflow workflow_path if workflow_path.exists? knowledge_base ||= KnowledgeBase.new knowledge_base_path if knowledge_base_path.exists? chat ||= Chat.setup LLM.chat(chat_path.find) if chat_path.exists? LLM::Agent.new workflow: workflow, knowledge_base: knowledge_base, start_chat: chat end |
.nested_save?(path) ⇒ Boolean
Is path the chat file of a NESTED conversation, i.e. does it already
live inside a society tree? A nested chat file sits at
The society directory of a depth-0 chat is named after it
(
68 69 70 71 |
# File 'lib/scout/llm/agent/save.rb', line 68 def nested_save?(path) parent = File.basename(File.dirname(File.dirname(File.dirname(path.to_s)))) parent == SOCIETY_DIR || parent.end_with?('.' + SOCIETY_DIR) end |
.society_dir_for(chat_path) ⇒ Object
Society directory of a ROOT chat saved at chat_path:
.../<name>.society for a chat file .../<name>.chat
(agent.chat -> agent.society; worker.chat -> worker.society).
ScoutCoder: this is the NAME-DERIVED rule, used at depth 0 only. A
path that does not end in .chat is normalized by dropping its last
component (defensive: society_dir_for(dir) still yields a directory
sibling, never <dir>.files/...); both Path and String inputs are
accepted and the result is always a plain String.
This is the ROOT rule only. The general rule (Agent#society_dir) is
decided from the location of the chat being written, so a nested
agent.chat never grows a second .files tree of its own:
chat.chat
chat.chat.society/<agent>/<conversation>/agent.chat
chat.chat.society/<agent>/<conversation>/society/<agent>/<conversation>/agent.chat
44 45 46 47 48 |
# File 'lib/scout/llm/agent/save.rb', line 44 def society_dir_for(chat_path) p = chat_path.to_s p = File.dirname(p) unless p =~ /\.chat\z/ File.join(File.dirname(p), File.basename(p).sub(/\.chat\z/, '.society')) end |
Instance Method Details
#ask(messages = nil, options = {}) ⇒ Object
function: takes an array of messages and calls LLM.ask with them
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/scout/llm/agent.rb', line 65 def ask( = nil, = {}) , = nil, if .empty? && Hash === = current_chat if .nil? = [] unless .is_a? Array model ||= @model if model no_ask_override = [:no_ask_override] .delete_if{|info| info[:role] == 'agent' } begin if workflow && workflow.tasks.include?(:ask) && ! no_ask_override .each do |key,value| .push(IndiferentHash.setup({role: :sticky_option, content: "#{key} #{value}"})) end .except(:return_messages).each do |key,value| .push(IndiferentHash.setup({role: :sticky_option, content: "#{key} #{value}"})) end job = workflow.job(:ask, chat: Chat.print()) self.job = job Open.mkdir job.files_dir Chat.allow_job job job.clean if ENV['SCOUT_NO_ASK_CACHE'] == 'true' job.recursive_clean if ENV['SCOUT_NO_ASK_CACHE'] == 'recursive' job.produce = Chat.project(job.short_path, LLM.chat(job.path)) if [:return_messages] Chat.setup() else Chat.setup().answer end else if (list = .select{|info| info[:role] == 'socialize'}).any? = list.last[:content] .delete_if{|info| info[:role] == 'socialize' } self.(.dup) if && %w(true TRUE True T 1).include?(.to_s) end tools = [:tools] || {} if other_tools = [:tools] other_tools = JSON.parse other_tools if String === other_tools tools = tools.merge other_tools end if workflow || knowledge_base tools.merge!(LLM.workflow_tools(workflow)) if workflow && workflow.tasks.any? tools.merge!(LLM.knowledge_base_tool_definition(knowledge_base)) if knowledge_base and knowledge_base.all_databases.any? end [:tools] = tools LLM.ask , .except(:no_ask_override).merge(log_errors: true).merge().merge(agent: false) end rescue exception = $! if Proc === self.process_exception try_again = self.process_exception.call exception if try_again retry else raise exception end else begin self.save if self.save_file rescue Log.exception $! ensure raise exception end end end end |
#ask_agent(agent_name, prompt, conversation: nil, inherit: 'tools', options: {}) ⇒ Object
Ask a specialist using a plain-text prompt.
With no conversation identifier this is a one-shot call. With an identifier, later calls continue the same specialist instance. The inheritance policy seeds a new call or conversation after the specialist's own start_chat:
- none: no context from the caller
- tools: only declarative tooling from the caller's task chat
- conversation: the caller's complete task chat
ScoutCoder: Agent#prompt parses String input as Scout chat-file syntax. Delegated prompts must instead be appended with Agent#user so role-looking text such as "tool:" cannot turn into a control message or grant tools.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/scout/llm/agent/delegate.rb', line 57 def ask_agent(agent_name, prompt, conversation: nil, inherit: 'tools', options: {}) raise ParameterException, 'The delegated prompt must be a String' unless String === prompt agent_name = (agent_name) inherit = (inherit) agent = if conversation.nil? load_chat(agent_name, , 'default', inherit: inherit) else conversation = (conversation) load_chat(agent_name, , conversation, inherit: inherit) end agent.user(prompt) agent end |
#chat(options = {}) ⇒ Object
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/scout/llm/agent/chat.rb', line 39 def chat( = {}) response = ask(current_chat, .merge(return_messages: true)) if Array === response current_chat.concat(response) if [:return_messages] response else current_chat.answer end else current_chat.push({role: :assistant, content: response}) response end ensure # Auto-save once the conversation has been updated by this chat round. # Non-fatal by design: a save problem must never break the agent run. begin save_if_configured rescue Log.warn "Agent auto-save after chat failed: #{$!.message}" end end |
#create_image(file, options = {}) ⇒ Object
62 63 64 |
# File 'lib/scout/llm/agent/chat.rb', line 62 def create_image(file, = {}) current_chat.create_image(file, .merge()) end |
#current_chat ⇒ Object
27 28 29 |
# File 'lib/scout/llm/agent/chat.rb', line 27 def current_chat @current_chat ||= start end |
#delegate(agent, name, description, task_name = nil, &block) ⇒ Object
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/scout/llm/agent/delegate.rb', line 142 def delegate(agent, name, description, task_name = nil, &block) [:tools] ||= {} task_name = "hand_off_to_#{name}".to_sym if task_name.nil? block ||= Proc.new do |_name, parameters| = parameters[:message] new_conversation = parameters[:new_conversation] Log.medium "Delegated to #{agent}: " + Log.fingerprint() agent.start if new_conversation agent.user agent end properties = { message: { "type": :string, "description": "Message to pass to the agent" }, new_conversation: { "type": :boolean, "description": "Erase conversation history and start a new conversation with this message", "default": false } } required_inputs = [:message] function = { name: task_name, description: description, parameters: { type: "object", properties: properties, required: required_inputs } } definition = IndiferentHash.setup function.merge(type: 'function', function: function) [:tools][task_name] = [block, definition] end |
#format_message(message, prefix = "user") ⇒ Object
39 40 41 42 43 |
# File 'lib/scout/llm/agent.rb', line 39 def (, prefix = "user") .split(/\n\n+/).reject{|line| line.empty? }.collect do |line| prefix + "\t" + line.gsub("\n", ' ') end * "\n" end |
#get_previous_response_id ⇒ Object
def json_format(format, options = {}) current_chat.format format output = ask(current_chat, options.merge(false)) current_chat.format nil obj = begin obj = Chat.parse_json output rescue JSON::ParserError Log.warn "Not valid JSON:" + output raise $! end if (Hash === obj) and obj.keys == ['content'] obj else obj end end
121 122 123 124 |
# File 'lib/scout/llm/agent/chat.rb', line 121 def get_previous_response_id msg = current_chat.reverse.find{|msg| msg[:role].to_sym == :previous_response_id } msg.nil? ? nil : msg['content'] end |
#iterate(prompt = nil, &block) ⇒ Object
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# File 'lib/scout/llm/agent/iterate.rb', line 4 def iterate(prompt = nil, &block) self.endpoint :responses self.user prompt if prompt obj = self.json_format({ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "content": { "type": "array", "items": { "type": "string" } } }, "required": ["content"], "additionalProperties": false }) self.option :format, :text list = Hash === obj ? obj['content'] : obj list.each &block end |
#iterate_dictionary(prompt = nil, **kwargs, &block) ⇒ Object
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/scout/llm/agent/iterate.rb', line 28 def iterate_dictionary(prompt = nil, **kwargs, &block) self.endpoint :responses self.user prompt if prompt dict = self.json_format({ name: 'dictionary', type: 'object', properties: {}, additionalProperties: {type: :string} }) self.option :format, :text TSV.traverse dict, **kwargs, &block end |
#json ⇒ Object
66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/scout/llm/agent/chat.rb', line 66 def json(...) current_chat.format :json output = chat(...) current_chat.format nil obj = Chat.parse_json output if (Hash === obj) and obj.keys == ['content'] obj['content'] else obj end end |
#json_format(format) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/scout/llm/agent/chat.rb', line 78 def json_format(format, ...) old_format = current_chat.remove_role :format current_chat.format format output = chat(...) current_chat.remove_role :format current_chat.concat old_format obj = Chat.parse_json output if (Hash === obj) and obj.keys == ['content'] obj['content'] else obj end end |
#load_agent(agent_name, options = {}) ⇒ Object
Load one immutable template per specialist. Conversations clone this template so that their current chats and start chats remain independent.
16 17 18 19 20 21 22 23 |
# File 'lib/scout/llm/agent/delegate.rb', line 16 def load_agent(agent_name, = {}) agent_name = (agent_name) @society ||= {} @society[agent_name] ||= begin agent = LLM.load_agent(agent_name, ()) agent end end |
#load_chat(agent_name, options = {}, conversation = nil, inherit: 'tools') ⇒ Object
Return a persistent specialist conversation. Conversation identifiers are
scoped by specialist, so Worker/work_A and Critic/work_A cannot collide.
inherit is only used when the conversation is first created.
28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/scout/llm/agent/delegate.rb', line 28 def load_chat(agent_name, = {}, conversation = nil, inherit: 'tools') agent_name = (agent_name) conversation = (conversation) inherit = (inherit) @chats ||= {} key = (agent_name, conversation) @chats[key] ||= begin agent = (agent_name, , inherit) agent.save_file = society_save_file(agent_name, conversation) agent end end |
#nested_save?(path) ⇒ Boolean
96 97 98 |
# File 'lib/scout/llm/agent/save.rb', line 96 def nested_save?(path) self.class.nested_save?(path) end |
#prompt(messages, options = {}) ⇒ Object
143 144 145 146 147 |
# File 'lib/scout/llm/agent.rb', line 143 def prompt(, = {}) = LLM.chat if String === = Chat.follow start_chat, ask , end |
#respond ⇒ Object
35 36 37 |
# File 'lib/scout/llm/agent/chat.rb', line 35 def respond(...) self.ask(current_chat, ...) end |
#save(path = nil, visited: nil, seen_agents: nil, depth: 0) ⇒ Object
Save the current conversation of this agent and of every nested
specialist conversation (chats) reachable from it.
path defaults to the configured save_file; when both are nil a
ScoutException is raised explaining how to configure the target.
Serialization: the FULL current_chat is written, not the delta against start_chat. These files are recovery/restart artifacts, so the saved conversation must be complete. AgentWorkflow#chat_task keeps its own delta semantics (result = current_chat - start_chat) for job results.
Layout: this agent's chat is written at path; each society entry is
written at <society_dir(path)>/<agent_name>/<conversation>/agent.chat,
where society_dir is computed from the LOCATION of path (see
Agent#society_dir), so nested societies always sit next to their
agent.chat and the tree nests one level deeper each time.
Children are saved by recursing into child.save(child_path, ...), and
each child's save_file is assigned so a later independent ask by that
child auto-saves to exactly the same place.
Cycle safety: visited (resolved target paths) and seen_agents
(agent objects) are threaded through the recursion; a target path or an
agent already saved by this run is skipped with a debug log, and
SAVE_DEPTH_LIMIT stops (with a warning, not an exception) anything that
still runs away.
Lazy: nothing is created eagerly. Open.sensible_write makes parent
directories on demand, so an agent with no live society writes ONLY its
chat file and no .files tree appears.
Returns the sorted Array of absolute paths whose content is now on disk and current, whether this call just wrote them or found them already up to date (an unchanged file is not rewritten but IS reported, because the useful contract is "these files now hold this state").
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/scout/llm/agent/save.rb', line 135 def save(path = nil, visited: nil, seen_agents: nil, depth: 0) path ||= save_file raise ScoutException, "No save file for agent. Pass a path or configure agent.save_file = <chat file>" if path.nil? path = path.to_s visited ||= Set.new seen_agents ||= Set.new resolved = File.(path) if depth > SAVE_DEPTH_LIMIT Log.warn "Agent save deeper than #{SAVE_DEPTH_LIMIT} levels under #{resolved}; stopping recursion" return [] end if visited.include?(resolved) Log.debug "Agent save: conversation already saved at #{resolved}" return [] end if seen_agents.include?(self) Log.debug "Agent save: agent already saved in this run, skipping #{resolved}" return [] end # current_chat is normally never nil (it lazily builds from start_chat), # but be explicit: an agent asked to save before any conversation exists # saves its (possibly empty) start-based chat rather than crashing. chat = current_chat || start content = chat.print # Recovery artifact: always complete, so refresh it when it changed # (Open.sensible_write alone would keep the first version forever). if Open.exist?(path) && Open.read(path) == content Log.debug "Agent save: #{resolved} is up to date" else Open.sensible_write(path, content, force: true) end visited << resolved seen_agents << self written = [] # Report only files that verifiably hold the expected content. written << resolved if Open.exist?(path) save_society(path, visited, seen_agents, depth, written) written.sort end |
#save_if_configured ⇒ Object
Auto-save after a turn that changed this agent's conversation. Only fires on agents with a configured save_file and is never fatal (a save failure is logged and the agent run continues). It is a full recursive save, so the tree stays complete after any turn anywhere in it.
188 189 190 191 |
# File 'lib/scout/llm/agent/save.rb', line 188 def save_if_configured return if save_file.nil? save end |
#save_restart_snapshot ⇒ Object
Snapshot the conversation as it stood before a restart, so a restarted conversation can still be recovered. Writes only when there is a prior, non-empty current_chat (lazy: no empty resets), and never lets a save problem break the restart. Timestamp collisions (two restarts within the same millisecond) get a _1, _2, ... suffix instead of clobbering.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/scout/llm/agent/save.rb', line 198 def save_restart_snapshot return if save_file.nil? prior = @current_chat return unless Chat === prior && prior.any? dir = File.join("#{save_file}.files", 'resets') # Colons are legal on unix but hostile to portability and tooling base = Chat..gsub(':', '') file = File.join(dir, "#{base}.chat") suffix = 0 while Open.exist?(file) suffix += 1 file = File.join(dir, "#{base}_#{suffix}.chat") end Open.sensible_write(file, prior.print) nil end |
#socialize(options = {}) ⇒ Object
Expose a deliberately narrow ask tool to this agent. The model can send
only a specialist name, a plain-text prompt, an optional persistent
conversation identifier, and an inheritance policy. It never receives or
edits the specialist's Chat object.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/scout/llm/agent/delegate.rb', line 79 def ( = {}) [:tools] ||= {} @society ||= {} = ( || {}) task_name = :ask block = Proc.new do |_name, parameters| begin agent_name, prompt, conversation, inherit = (parameters) ask_agent(agent_name, prompt, conversation: conversation, inherit: inherit, options: ) rescue ScoutException => e e end end properties = { agent: { type: 'string', description: 'Name of the specialist agent to ask' }, prompt: { type: 'string', description: 'Plain-text prompt sent as one user message to the specialist' }, conversation: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$', description: 'Optional conversation or chat identifier; reuse it with the same agent to continue that conversation across several calls' }, inherit: { type: 'string', enum: SOCIAL_INHERIT_MODES, default: 'tools', 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" } } description = "Ask another agent and receive only its text answer. Omit `conversation` for an\nindependent one-shot call. Set `conversation` to a name and reuse the same name\nwith the same agent for follow-up turns. `inherit` controls only how a new call\nor named conversation is initialized; follow-up turns retain their own history.\nThe specialist's own start_chat is always applied first.\n EOF\n\n function = {\n name: task_name,\n description: description,\n parameters: {\n type: 'object',\n properties: properties,\n required: [:agent, :prompt],\n additionalProperties: false\n }\n }\n\n definition = IndiferentHash.setup(function.merge(type: 'function', function: function))\n @other_options[:tools][task_name] = [block, definition]\nend\n" |
#society_dir(path) ⇒ Object
Society directory for the chat written at path, whatever the entry
point was (root save, recursion, or a later standalone auto-save of a
child that had its save_file assigned by a parent):
- root chat (
.../conversation.chat): sibling<name>.society - nested chat (
.../society/<a>/<c>/<file>): sibling<dirname>/society
Deciding by LOCATION (not by recursion depth) is what keeps the layout
canonical: a nested agent.chat never produces <nested>.files/..., no
matter who triggered the save.
88 89 90 91 92 93 94 |
# File 'lib/scout/llm/agent/save.rb', line 88 def society_dir(path) if self.class.nested_save?(path) File.join(File.dirname(path), SOCIETY_DIR) else self.class.society_dir_for(path) end end |
#society_dir_for(chat_path) ⇒ Object
74 75 76 |
# File 'lib/scout/llm/agent/save.rb', line 74 def society_dir_for(chat_path) self.class.society_dir_for(chat_path) end |
#start(chat = nil) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# File 'lib/scout/llm/agent/chat.rb', line 7 def start(chat=nil) # Restart hook: keep the pre-restart conversation recoverable before it # is replaced. Lazy (only with a prior non-empty chat + configured # save_file) and never fatal. begin save_restart_snapshot rescue Log.warn "Agent restart snapshot failed: #{$!.message}" end if chat (@current_chat || start_chat).annotate chat unless Chat === chat @current_chat = chat else start_chat = self.start_chat Chat.setup(start_chat) unless Chat === start_chat @current_chat = start_chat.branch end end |