Class: LLM::Agent

Inherits:
Object
  • Object
show all
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 current_meta format messages no_ask_override previous_response_id
  process return_messages tool_choice tools
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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
  @other_options = 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

#chatsObject

Returns the value of attribute chats.



12
13
14
# File 'lib/scout/llm/agent/delegate.rb', line 12

def chats
  @chats
end

#jobObject

Returns the value of attribute job.



13
14
15
# File 'lib/scout/llm/agent.rb', line 13

def job
  @job
end

#knowledge_baseObject

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_optionsObject

Returns the value of attribute other_options.



13
14
15
# File 'lib/scout/llm/agent.rb', line 13

def other_options
  @other_options
end

#pathObject

Returns the value of attribute path.



13
14
15
# File 'lib/scout/llm/agent.rb', line 13

def path
  @path
end

#process_exceptionObject

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_fileObject

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

#societyObject

Returns the value of attribute society.



12
13
14
# File 'lib/scout/llm/agent/delegate.rb', line 12

def society
  @society
end

#start_chatObject

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, options = {})
  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 **options.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 ///, so the directory three levels up from the file is the society directory itself. (Two levels up would be , one level up .)

The society directory of a depth-0 chat is named after it (.society), while every deeper level keeps the plain 'society' sibling dir, so BOTH basenames count here.

Returns:



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(messages = nil, options = {})
  messages, options = nil, messages if options.empty? && Hash === messages
  messages = current_chat if messages.nil?
  messages = [messages] unless messages.is_a? Array
  model ||= @model if model

  no_ask_override = @other_options[:no_ask_override]

  messages.delete_if{|info| info[:role] == 'agent' }
  begin

    if workflow && workflow.tasks.include?(:ask) && ! no_ask_override
      other_options.each do |key,value|
        messages.push(IndiferentHash.setup({role: :sticky_option, content: "#{key} #{value}"})) 
      end

      options.except(:return_messages).each do |key,value|
        messages.push(IndiferentHash.setup({role: :sticky_option, content: "#{key} #{value}"})) 
      end

      job = workflow.job(:ask, chat: Chat.print(messages))
      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
      
      messages = Chat.project(job.short_path, LLM.chat(job.path))
      if options[:return_messages]
        Chat.setup(messages)
      else
        Chat.setup(messages).answer
      end
    else

      if (list = messages.select{|info| info[:role] == 'socialize'}).any?
        socialize = list.last[:content]
        messages.delete_if{|info| info[:role] == 'socialize' }
        self.socialize(options.dup) if socialize && %w(true TRUE True T 1).include?(socialize.to_s)
      end

      tools = options[:tools] || {}
      if other_tools = @other_options[: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

      options[:tools] = tools
      LLM.ask messages, @other_options.except(:no_ask_override).merge(log_errors: true).merge(options).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.

Raises:



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 = normalize_social_agent_name(agent_name)
  inherit = normalize_social_inherit(inherit)

  agent = if conversation.nil?
            load_chat(agent_name, options, 'default', inherit: inherit)
          else
            conversation = normalize_social_conversation_name(conversation)
            load_chat(agent_name, options, 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(options = {})
  response = ask(current_chat, options.merge(return_messages: true))
  if Array === response
    current_chat.concat(response)
    if options[: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, options = {})
  current_chat.create_image(file, @other_options.merge(options))
end

#current_chatObject



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)
  @other_options[:tools] ||= {}
  task_name = "hand_off_to_#{name}".to_sym if task_name.nil?

  block ||= Proc.new do |_name, parameters|
    message = parameters[:message]
    new_conversation = parameters[:new_conversation]
    Log.medium "Delegated to #{agent}: " + Log.fingerprint(message)
    agent.start if new_conversation
    agent.user message
    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)

  @other_options[: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 format_message(message, prefix = "user")
  message.split(/\n\n+/).reject{|line| line.empty? }.collect do |line|
    prefix + "\t" + line.gsub("\n", ' ')
  end * "\n"
end

#get_previous_response_idObject

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

#jsonObject



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, options = {})
  agent_name = normalize_social_agent_name(agent_name)
  @society ||= {}
  @society[agent_name] ||= begin
                             agent = LLM.load_agent(agent_name, social_agent_options(options))
                             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, options = {}, conversation = nil, inherit: 'tools')
  agent_name = normalize_social_agent_name(agent_name)
  conversation = normalize_social_conversation_name(conversation)
  inherit = normalize_social_inherit(inherit)

  @chats ||= {}
  key = social_chat_key(agent_name, conversation)
  @chats[key] ||= begin 
                    agent = start_social_chat(agent_name, options, inherit)
                    agent.save_file = society_save_file(agent_name, conversation)
                    agent
                  end

end

#nested_save?(path) ⇒ Boolean

Returns:



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(messages, options = {})
  messages = LLM.chat messages if String === messages
  messages = Chat.follow start_chat, messages
  ask messages, options
end

#respondObject



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").

Raises:



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.expand_path(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_configuredObject

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_snapshotObject

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.timestamp.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 socialize(options = {})
  @other_options[:tools] ||= {}
  @society ||= {}
  social_options = social_duplicate(options || {})

  task_name = :ask
  block = Proc.new do |_name, parameters|
    begin
      agent_name, prompt, conversation, inherit = social_tool_parameters(parameters)
      ask_agent(agent_name, prompt,
                conversation: conversation,
                inherit: inherit,
                options: social_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