Class: Ask::Agent::Session
- Inherits:
-
Object
- Object
- Ask::Agent::Session
- Includes:
- Test::SessionOverride
- Defined in:
- lib/ask/agent/session.rb
Constant Summary collapse
- RECENTLY_COMPLETED_MAX =
Max ids remembered as "recently completed" to guard against late loop registrations resurrecting ghost pending calls.
200
Instance Attribute Summary collapse
-
#approval_queue ⇒ Ask::Agent::ApprovalQueue?
readonly
The approval queue backing this session, or nil when the session was created without approval support.
-
#artifact_store ⇒ Ask::Agent::ArtifactStore?
readonly
Store for tool deliverables (only when the
artifacts:option is enabled). -
#chat ⇒ Object
readonly
Returns the value of attribute chat.
-
#created_at ⇒ Object
readonly
Returns the value of attribute created_at.
-
#id ⇒ Object
readonly
Returns the value of attribute id.
-
#memory ⇒ Ask::Agent::Memory?
readonly
Durable memory (only when passed via the
memory:option). -
#messages ⇒ Object
readonly
Returns the value of attribute messages.
-
#meta_agent_results ⇒ Object
readonly
Returns the value of attribute meta_agent_results.
-
#output_store ⇒ Ask::Agent::ToolOutputStore?
readonly
Store for offloaded large tool outputs (only when large-output offloading is enabled).
-
#plan_queue ⇒ Ask::Agent::ApprovalQueue?
readonly
Queue carrying plan approvals (only when plan mode is enabled).
-
#skills_registry ⇒ Ask::Skills::Registry?
readonly
Auto-discovered skills registry.
-
#todo_list ⇒ Ask::Agent::TodoList?
readonly
Session task list (only when todos are enabled).
-
#tool_calls_made ⇒ Object
readonly
Returns the value of attribute tool_calls_made.
-
#tools ⇒ Object
readonly
Returns the value of attribute tools.
-
#total_cost ⇒ Object
readonly
Returns the value of attribute total_cost.
-
#total_input_tokens ⇒ Object
readonly
Returns the value of attribute total_input_tokens.
-
#total_output_tokens ⇒ Object
readonly
Returns the value of attribute total_output_tokens.
-
#turn_count ⇒ Object
readonly
Returns the value of attribute turn_count.
-
#turn_id ⇒ Integer
readonly
Id of the turn currently running (or the last completed turn when idle).
Class Method Summary collapse
-
.deep_symbolize_keys(obj) ⇒ Object
Recursively convert string keys to symbol keys in hashes.
-
.deserialize_content(content) ⇒ Object
Rebuild message content from a persisted value: block hashes are reconstructed via Ask::Content.from_h (deep_symbolize_keys may have symbol keys — from_h normalizes).
- .load(id, adapter:) ⇒ Object
-
.serialize_content(message) ⇒ Object
Persist content blocks as their
to_hhashes so attachments survive save/load; plain messages stay strings.
Instance Method Summary collapse
- #abort ⇒ Object
- #abort_requested? ⇒ Boolean
- #approve_plan(action) ⇒ Object
-
#artifacts ⇒ Array<Hash>
Artifact summaries for this session (id, filename, mime_type, size, uri), newest first.
-
#checkpoint_history ⇒ Array<Integer>
Checkpoint seqs, oldest first.
-
#complete_pending_tool(tool_call_id:, result:) ⇒ Boolean
Completes a pending (async) tool call from a background thread.
- #delete ⇒ Object
- #deleted? ⇒ Boolean
-
#drain_leftover_steers ⇒ Object
Move any queued steers left over from a previous run into the conversation (the session was idle, so they are dispatched now).
-
#drain_one_steer ⇒ Object
Pop the next queued steer (called by the loop at each turn boundary); returns "" when nothing is queued.
- #emit(event) ⇒ Object
-
#extract_memories ⇒ Object
Extract durable facts from this session's transcript into memory (memory_learning: true).
-
#fetch_artifact(id) ⇒ Hash?
The full record (content or uri).
-
#fork(at_seq: nil, at_turn: nil) ⇒ Ask::Agent::Session
Fork the session at a checkpoint: a new session (new id, same model and tools) whose history is everything up to that point, backed by its own checkpoint chain.
-
#initialize(model:, tools: [], max_turns: 25, max_tool_retries: 3, compactor: nil, hooks: {}, state: nil, persistence: nil, id: nil, system_prompt: nil, parallel_tools: true, reflector: nil, telemetry: true, meta_agent: nil, agent_dir: nil, evaluator: nil, audit_log: nil, skills_disclosure: true, approval: nil, tool_call_repair: nil, checkpoints: false, todos: false, plan_mode: false, memory: nil, memory_learning: false, offload_large_outputs: false, artifacts: false, artifact_uploader: nil, **chat_options) ⇒ Session
constructor
A new instance of Session.
-
#inject_memories(message) ⇒ Object
Retrieve memories relevant to the incoming message and inject them as a system message, so a new session starts with what earlier sessions learned.
-
#load_checkpoint(seq: nil) ⇒ Hash?
Load a checkpoint's snapshot.
- #on(type, &block) ⇒ Object
- #on_event(&block) ⇒ Object
-
#pending_tools? ⇒ Boolean
True while at least one async tool is running.
-
#plan_mode? ⇒ Boolean
Whether the session is in plan mode (research phase; non-read-only tools are blocked until the plan is approved).
-
#plan_mode_gate(tool_call, _context) ⇒ Object
Before-tool gate active while in plan mode: only read-only tools (and exit_plan_mode itself) run until a human approves the plan.
-
#queued_steers ⇒ Integer
Steers queued and not yet dispatched.
- #reflection_count ⇒ Object
-
#register_pending_tool(tool_call_id, result) ⇒ Object
Registers a pending tool call (called by the loop when a tool returned Ask::Result.pending, or at approval-queue submit time).
- #reject_plan(action) ⇒ Object
- #reset_messages! ⇒ Object
-
#rollback!(seq: nil, turn: nil) ⇒ self
Rewind the session to an earlier checkpoint: messages and turn count are restored from the snapshot, and the store's head moves back.
- #run(message, tools: nil, reset: true, attachments: nil) ⇒ Object
-
#run_follow_up ⇒ Object
A follow-up turn driven by an async completion: runs the loop with the tool message already in the conversation, preserving turn state.
- #running? ⇒ Boolean
- #save ⇒ Object
-
#skill(name) ⇒ Object
Load a skill by name or file path.
-
#steer(message, expected_turn_id: nil, attachments: nil) ⇒ Hash
Inject a message into the session safely, from any thread (web, CLI, another agent):.
Methods included from Test::SessionOverride
#called_tool?, #stub_text, #stub_tool_call, #test_mode
Constructor Details
#initialize(model:, tools: [], max_turns: 25, max_tool_retries: 3, compactor: nil, hooks: {}, state: nil, persistence: nil, id: nil, system_prompt: nil, parallel_tools: true, reflector: nil, telemetry: true, meta_agent: nil, agent_dir: nil, evaluator: nil, audit_log: nil, skills_disclosure: true, approval: nil, tool_call_repair: nil, checkpoints: false, todos: false, plan_mode: false, memory: nil, memory_learning: false, offload_large_outputs: false, artifacts: false, artifact_uploader: nil, **chat_options) ⇒ Session
Returns a new instance of Session.
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 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 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 183 184 185 186 187 188 189 190 191 |
# File 'lib/ask/agent/session.rb', line 24 def initialize(model:, tools: [], max_turns: 25, max_tool_retries: 3, compactor: nil, hooks: {}, state: nil, persistence: nil, id: nil, system_prompt: nil, parallel_tools: true, reflector: nil, telemetry: true, meta_agent: nil, agent_dir: nil, evaluator: nil, audit_log: nil, skills_disclosure: true, approval: nil, tool_call_repair: nil, checkpoints: false, todos: false, plan_mode: false, memory: nil, memory_learning: false, offload_large_outputs: false, artifacts: false, artifact_uploader: nil, **) @id = id || SecureRandom.uuid @agent_dir = agent_dir @max_turns = max_turns @max_tool_retries = max_tool_retries @parallel_tools = parallel_tools @skills_disclosure = skills_disclosure @event_handlers = { all: [] } @running = false @deleted = false @abort_requested = false @pending_tools = {} @recently_completed = [] @pending_mutex = Mutex.new @followup_pending = false @turn_count = 0 # Concurrency-safe steering: turn id bumped at every # TurnStart; steers arriving mid-turn are queued and dispatched at # the next turn boundary. @turn_id = 0 @queued_steers = [] @steer_mutex = Mutex.new on(Events::TurnStart) { @turn_id += 1 } @created_at = Time.now @_no_tools_instructed = false @total_input_tokens = 0 @total_output_tokens = 0 @total_cost = 0.0 @telemetry = telemetry.is_a?(Telemetry) ? telemetry : Telemetry.new(enabled: !!telemetry) # Task list (todo_write tool) — built before resolve_tools so the # tool can be injected with a reference to it. @todos_enabled = !!todos @todo_list = TodoList.new if @todos_enabled @todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) } # Durable memory (memory_write / memory_search tools). An instance # with its own namespace and state adapter; nil disables memory. @memory = memory # Learning: extract durable facts from the transcript when the # session ends (requires memory). if memory_learning && !@memory raise ArgumentError, "memory_learning: requires a memory: instance" end @memory_learning = !!memory_learning # Large-output offloading: tool results above a size threshold are # stored in a ToolOutputStore (state adapter when present, else # in-process) and the transcript keeps a preview + reference. @offload_threshold = case offload_large_outputs when true then 4000 when Integer then offload_large_outputs else nil end @output_store = if @offload_threshold ToolOutputStore.new(state: state || persistence || Ask::State::Memory.new) end # Tool deliverables (artifacts): metadata[:artifact] on tool results # is collected into the store — inline content for small text, # external URIs for large binaries (uploader lifts content to a URI # when provided). @artifact_store = if artifacts ArtifactStore.new( state: state || persistence || Ask::State::Memory.new, uploader: artifact_uploader ) end # Plan mode — research phase gated to read-only tools until a human # approves the model's plan (submitted via the exit_plan_mode tool). @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode @plan_mode_read_only_tools = if plan_mode.is_a?(Hash) && plan_mode[:read_only_tools] Array(plan_mode[:read_only_tools]).map(&:to_s) else %w[read glob grep web_search] end @plan_queue = ApprovalQueue.new( on_approve: ->(action) { approve_plan(action) }, on_reject: ->(action) { reject_plan(action) }, # Same race closure as the tool approval queue: register the # pending call at submit time so plan approvals land even when # the executor is still in flight. on_submit: ->(action) { register_pending_tool(action.tool_call_id, { tool_name: action.tool_name, message: action. || "Plan awaiting approval", status: "pending", tool_call_id: action.tool_call_id, action_id: action.id }) } ) if @plan_mode @tools = resolve_tools(tools) @chat = build_chat(model, system_prompt, @tools, **) @loop = Loop.new(max_turns: max_turns) @tool_executor = ToolExecutor.new( max_retries: max_tool_retries, parallel: parallel_tools, output_offload_threshold: @offload_threshold, output_store: @output_store, artifact_store: @artifact_store ) @compactor = compactor ? build_compactor(compactor) : nil @hooks = Hooks.new(hooks) @audit_log = build_audit_log(audit_log) @approval_queue = build_approval(approval) @tool_call_repair = tool_call_repair # Plan gate runs before user hooks and the approval policy: in plan # mode, non-read-only tools are blocked outright (never queued). if @plan_mode @hooks = Hooks.new( before_tool: [method(:plan_mode_gate)] + Array(@hooks.instance_variable_get(:@before_tool)), after_tool: @hooks.instance_variable_get(:@after_tool) ) end @system_context = build_system_context(system_prompt) apply_system_context @state = state || persistence if checkpoints && !@state raise ArgumentError, "checkpoints: requires a state: adapter" end @checkpoints = !!checkpoints @checkpoint_store = CheckpointStore.new(@state) if @checkpoints reflector_opts = reflector.is_a?(Hash) ? reflector : {} @reflector = if reflector Reflector.new( model: @chat, max_reflections: reflector_opts[:max_reflections] || 1 ) end @meta_agent_config = @meta_agent_results = nil @compactor&.chat = @chat # Parse evaluator configuration @evaluator = nil @evaluator_config = {} if evaluator eval_model = if evaluator.is_a?(Hash) @evaluator_config = evaluator evaluator[:model] || Ask::Agent.configuration.default_evaluator_model || model_id_from(@chat) else Ask::Agent.configuration.default_evaluator_model || model_id_from(@chat) end @evaluator = Evaluator.new(model: eval_model) end end |
Instance Attribute Details
#approval_queue ⇒ Ask::Agent::ApprovalQueue? (readonly)
The approval queue backing this session, or nil when the session was created without approval support. Use it to inspect pending actions and approve/reject them.
198 199 200 |
# File 'lib/ask/agent/session.rb', line 198 def approval_queue @approval_queue end |
#artifact_store ⇒ Ask::Agent::ArtifactStore? (readonly)
Returns store for tool deliverables
(only when the artifacts: option is enabled).
213 214 215 |
# File 'lib/ask/agent/session.rb', line 213 def artifact_store @artifact_store end |
#chat ⇒ Object (readonly)
Returns the value of attribute chat.
13 14 15 |
# File 'lib/ask/agent/session.rb', line 13 def chat @chat end |
#created_at ⇒ Object (readonly)
Returns the value of attribute created_at.
13 14 15 |
# File 'lib/ask/agent/session.rb', line 13 def created_at @created_at end |
#id ⇒ Object (readonly)
Returns the value of attribute id.
13 14 15 |
# File 'lib/ask/agent/session.rb', line 13 def id @id end |
#memory ⇒ Ask::Agent::Memory? (readonly)
Returns durable memory (only when passed
via the memory: option).
207 208 209 |
# File 'lib/ask/agent/session.rb', line 207 def memory @memory end |
#messages ⇒ Object (readonly)
Returns the value of attribute messages.
13 14 15 |
# File 'lib/ask/agent/session.rb', line 13 def @messages end |
#meta_agent_results ⇒ Object (readonly)
Returns the value of attribute meta_agent_results.
20 21 22 |
# File 'lib/ask/agent/session.rb', line 20 def @meta_agent_results end |
#output_store ⇒ Ask::Agent::ToolOutputStore? (readonly)
Returns store for offloaded large tool outputs (only when large-output offloading is enabled).
210 211 212 |
# File 'lib/ask/agent/session.rb', line 210 def output_store @output_store end |
#plan_queue ⇒ Ask::Agent::ApprovalQueue? (readonly)
Returns queue carrying plan approvals (only when plan mode is enabled).
201 202 203 |
# File 'lib/ask/agent/session.rb', line 201 def plan_queue @plan_queue end |
#skills_registry ⇒ Ask::Skills::Registry? (readonly)
Returns auto-discovered skills registry.
22 23 24 |
# File 'lib/ask/agent/session.rb', line 22 def skills_registry @skills_registry end |
#todo_list ⇒ Ask::Agent::TodoList? (readonly)
Returns session task list (only when todos are enabled).
204 205 206 |
# File 'lib/ask/agent/session.rb', line 204 def todo_list @todo_list end |
#tool_calls_made ⇒ Object (readonly)
Returns the value of attribute tool_calls_made.
14 15 16 |
# File 'lib/ask/agent/session.rb', line 14 def tool_calls_made @tool_calls_made end |
#tools ⇒ Object (readonly)
Returns the value of attribute tools.
13 14 15 |
# File 'lib/ask/agent/session.rb', line 13 def tools @tools end |
#total_cost ⇒ Object (readonly)
Returns the value of attribute total_cost.
14 15 16 |
# File 'lib/ask/agent/session.rb', line 14 def total_cost @total_cost end |
#total_input_tokens ⇒ Object (readonly)
Returns the value of attribute total_input_tokens.
14 15 16 |
# File 'lib/ask/agent/session.rb', line 14 def total_input_tokens @total_input_tokens end |
#total_output_tokens ⇒ Object (readonly)
Returns the value of attribute total_output_tokens.
14 15 16 |
# File 'lib/ask/agent/session.rb', line 14 def total_output_tokens @total_output_tokens end |
#turn_count ⇒ Object (readonly)
Returns the value of attribute turn_count.
13 14 15 |
# File 'lib/ask/agent/session.rb', line 13 def turn_count @turn_count end |
#turn_id ⇒ Integer (readonly)
Returns id of the turn currently running (or the last completed turn when idle).
667 668 669 |
# File 'lib/ask/agent/session.rb', line 667 def turn_id @turn_id end |
Class Method Details
.deep_symbolize_keys(obj) ⇒ Object
Recursively convert string keys to symbol keys in hashes. Needed when loading session data that was serialized through JSON.
1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 |
# File 'lib/ask/agent/session.rb', line 1130 def self.deep_symbolize_keys(obj) case obj when Hash obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize_keys(v) } when Array obj.map { |e| deep_symbolize_keys(e) } else obj end end |
.deserialize_content(content) ⇒ Object
Rebuild message content from a persisted value: block hashes are reconstructed via Ask::Content.from_h (deep_symbolize_keys may have symbol keys — from_h normalizes).
1042 1043 1044 |
# File 'lib/ask/agent/session.rb', line 1042 def self.deserialize_content(content) content.is_a?(Array) ? content.map { |block| Ask::Content.from_h(block) } : content end |
.load(id, adapter:) ⇒ Object
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 |
# File 'lib/ask/agent/session.rb', line 430 def self.load(id, adapter:) data = adapter.get(id) return nil unless data data = deep_symbolize_keys(data) session = new( id: data[:id], model: data.dig(:metadata, :model), # Restore saved user tools by class name. Tools that cannot be # restored — renamed/removed classes (NameError) or constructors # with required args (ArgumentError) — are skipped with a warning # instead of failing the whole load; resolve_tools re-adds the # framework-injected load_skill tool with a proper registry. tools: data.dig(:metadata, :tools).to_a.filter_map do |name| begin name.constantize.new rescue NameError, ArgumentError => e warn "[ask-agent] Session.load skipped tool '#{name}': #{e.class}: #{e.}" nil end end, state: adapter, # Checkpointing is restored automatically when the session has # checkpoints in the store; todos likewise when the snapshot has # a task list. checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?, todos: !data[:todos].nil? ) data[:messages].each do |msg| session.chat.( role: msg[:role].to_sym, content: deserialize_content(msg[:content]), tool_call_id: msg[:tool_call_id] ) end session.instance_variable_get(:@todo_list)&.restore(data[:todos]) session.instance_variable_set(:@messages, session.chat..dup) session end |
.serialize_content(message) ⇒ Object
Persist content blocks as their to_h hashes so attachments
survive save/load; plain messages stay strings.
1035 1036 1037 |
# File 'lib/ask/agent/session.rb', line 1035 def self.serialize_content() .content_blocks ? .content_blocks.map(&:to_h) : .content.to_s end |
Instance Method Details
#abort ⇒ Object
481 482 483 |
# File 'lib/ask/agent/session.rb', line 481 def abort @abort_requested = true end |
#abort_requested? ⇒ Boolean
485 |
# File 'lib/ask/agent/session.rb', line 485 def abort_requested? = @abort_requested |
#approve_plan(action) ⇒ Object
634 635 636 637 638 639 640 641 642 643 644 645 646 647 |
# File 'lib/ask/agent/session.rb', line 634 def approve_plan(action) @plan_mode = false plan = action.args[:plan] || action.args["plan"] || "" emit(Events::PlanApproved.new(plan: plan)) complete_pending_tool( tool_call_id: action.tool_call_id, result: { tool_name: "exit_plan_mode", message: "Plan approved — execute it now.", status: "success", is_error: false } ) end |
#artifacts ⇒ Array<Hash>
Returns artifact summaries for this session (id, filename, mime_type, size, uri), newest first.
569 570 571 572 |
# File 'lib/ask/agent/session.rb', line 569 def artifacts require_artifacts! @artifact_store.list(@id) end |
#checkpoint_history ⇒ Array<Integer>
Returns checkpoint seqs, oldest first.
491 492 493 494 |
# File 'lib/ask/agent/session.rb', line 491 def checkpoint_history require_checkpoints! @checkpoint_store.history(@id) end |
#complete_pending_tool(tool_call_id:, result:) ⇒ Boolean
Completes a pending (async) tool call from a background thread.
Adds the tool result to the conversation and, when the session is idle, runs a follow-up turn so the agent voices the answer. If a turn is running, the follow-up fires as soon as it ends.
741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 |
# File 'lib/ask/agent/session.rb', line 741 def complete_pending_tool(tool_call_id:, result:) follow_up = @pending_mutex.synchronize do pending = @pending_tools.delete(tool_call_id) return false unless pending # Remember the id briefly so a late loop registration (from a # completion that landed while the executor was in flight) cannot # resurrect it as a ghost pending call. @recently_completed << tool_call_id @recently_completed.shift if @recently_completed.size > RECENTLY_COMPLETED_MAX @chat.( role: :tool, content: result[:message].to_s, tool_call_id: tool_call_id ) if @running @followup_pending = true false else true end end emit(Events::ToolCompleted.new(name: result[:tool_name], id: tool_call_id, result: result)) run_follow_up if follow_up true end |
#delete ⇒ Object
473 474 475 476 477 478 479 |
# File 'lib/ask/agent/session.rb', line 473 def delete @deleted = true @checkpoint_store&.delete(@id) @output_store&.delete(@id) @artifact_store&.delete(@id) @state&.delete(@id) end |
#deleted? ⇒ Boolean
424 |
# File 'lib/ask/agent/session.rb', line 424 def deleted? = @deleted |
#drain_leftover_steers ⇒ Object
Move any queued steers left over from a previous run into the conversation (the session was idle, so they are dispatched now).
592 593 594 595 596 |
# File 'lib/ask/agent/session.rb', line 592 def drain_leftover_steers while ( = drain_one_steer) != "" @chat.(role: :user, content: ) end end |
#drain_one_steer ⇒ Object
Pop the next queued steer (called by the loop at each turn boundary); returns "" when nothing is queued.
586 587 588 |
# File 'lib/ask/agent/session.rb', line 586 def drain_one_steer @steer_mutex.synchronize { @queued_steers.shift }.to_s end |
#emit(event) ⇒ Object
417 418 419 420 421 |
# File 'lib/ask/agent/session.rb', line 417 def emit(event) @event_handlers[:all].each { |h| h.call(event) } handlers = @event_handlers[event.class] handlers&.each { |h| h.call(event) } end |
#extract_memories ⇒ Object
Extract durable facts from this session's transcript into memory (memory_learning: true). Best-effort — extraction never breaks the session; failures are swallowed.
601 602 603 604 605 606 |
# File 'lib/ask/agent/session.rb', line 601 def extract_memories extractor = MemoryExtractor.new(model: model_id_from(@chat), memory: @memory) extractor.extract(transcript: @chat., session_id: @id) rescue StandardError nil end |
#fetch_artifact(id) ⇒ Hash?
Returns the full record (content or uri).
577 578 579 580 |
# File 'lib/ask/agent/session.rb', line 577 def fetch_artifact(id) require_artifacts! @artifact_store.fetch(@id, id) end |
#fork(at_seq: nil, at_turn: nil) ⇒ Ask::Agent::Session
Fork the session at a checkpoint: a new session (new id, same model
and tools) whose history is everything up to that point, backed by
its own checkpoint chain. Continue the branch with run.
542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 |
# File 'lib/ask/agent/session.rb', line 542 def fork(at_seq: nil, at_turn: nil) require_checkpoints! seq = resolve_checkpoint_seq(at_seq, at_turn) data = load_checkpoint(seq: seq) raise ArgumentError, "no checkpoint #{seq}" unless data forked_id = @checkpoint_store.fork(@id, at_seq: seq) forked = self.class.new( id: forked_id, model: data[:metadata][:model], tools: @tools, state: @state, checkpoints: true, todos: @todos_enabled, plan_mode: @plan_mode ) restore_into(forked, data) emit(Events::SessionForked.new(session_id: @id, forked_id: forked_id, seq: seq)) forked end |
#inject_memories(message) ⇒ Object
Retrieve memories relevant to the incoming message and inject them as a system message, so a new session starts with what earlier sessions learned.
611 612 613 614 615 616 617 618 619 |
# File 'lib/ask/agent/session.rb', line 611 def inject_memories() hits = @memory.search(.to_s, limit: 5) return if hits.empty? @chat.( role: :system, content: "Relevant memories from previous sessions:\n" + hits.map { |e| "- #{e.content}" }.join("\n") ) end |
#load_checkpoint(seq: nil) ⇒ Hash?
Load a checkpoint's snapshot.
501 502 503 504 505 |
# File 'lib/ask/agent/session.rb', line 501 def load_checkpoint(seq: nil) require_checkpoints! data = @checkpoint_store.load(@id, seq: seq) data && self.class.deep_symbolize_keys(data) end |
#on(type, &block) ⇒ Object
411 412 413 414 415 |
# File 'lib/ask/agent/session.rb', line 411 def on(type, &block) @event_handlers[type] ||= [] @event_handlers[type] << block self end |
#on_event(&block) ⇒ Object
406 407 408 409 |
# File 'lib/ask/agent/session.rb', line 406 def on_event(&block) @event_handlers[:all] << block self end |
#pending_tools? ⇒ Boolean
Returns true while at least one async tool is running.
728 729 730 |
# File 'lib/ask/agent/session.rb', line 728 def pending_tools? @pending_mutex.synchronize { !@pending_tools.empty? } end |
#plan_mode? ⇒ Boolean
Returns whether the session is in plan mode (research phase; non-read-only tools are blocked until the plan is approved).
623 |
# File 'lib/ask/agent/session.rb', line 623 def plan_mode? = @plan_mode |
#plan_mode_gate(tool_call, _context) ⇒ Object
Before-tool gate active while in plan mode: only read-only tools (and exit_plan_mode itself) run until a human approves the plan.
627 628 629 630 631 632 |
# File 'lib/ask/agent/session.rb', line 627 def plan_mode_gate(tool_call, _context) return { action: :proceed } unless @plan_mode return { action: :proceed } if @plan_mode_read_only_tools.include?(tool_call.name) || tool_call.name == "exit_plan_mode" { action: :block, reason: "Plan mode: only read-only tools until the plan is approved" } end |
#queued_steers ⇒ Integer
Returns steers queued and not yet dispatched.
701 702 703 |
# File 'lib/ask/agent/session.rb', line 701 def queued_steers @steer_mutex.synchronize { @queued_steers.size } end |
#reflection_count ⇒ Object
16 17 18 |
# File 'lib/ask/agent/session.rb', line 16 def reflection_count @reflector&.reflection_count || 0 end |
#register_pending_tool(tool_call_id, result) ⇒ Object
Registers a pending tool call (called by the loop when a tool returned Ask::Result.pending, or at approval-queue submit time). The background work completes later via #complete_pending_tool.
A call can be resolved (approved/rejected) while the executor is still in flight; when the loop then registers the same call, the registration is skipped so no ghost pending entry is left behind.
714 715 716 717 718 719 720 721 722 723 724 725 |
# File 'lib/ask/agent/session.rb', line 714 def register_pending_tool(tool_call_id, result) @pending_mutex.synchronize do return if @recently_completed.include?(tool_call_id) if (action_id = result[:action_id]) && @approval_queue action = @approval_queue[action_id] return if action && action.status != :pending end @pending_tools[tool_call_id] = result end emit(Events::ToolPending.new(name: result[:tool_name], id: tool_call_id)) nil end |
#reject_plan(action) ⇒ Object
649 650 651 652 653 654 655 656 657 658 659 660 661 |
# File 'lib/ask/agent/session.rb', line 649 def reject_plan(action) plan = action.args[:plan] || action.args["plan"] || "" emit(Events::PlanRejected.new(plan: plan)) complete_pending_tool( tool_call_id: action.tool_call_id, result: { tool_name: "exit_plan_mode", message: "Plan rejected by the user — revise your plan and resubmit.", status: "rejected", is_error: false } ) end |
#reset_messages! ⇒ Object
800 801 802 803 |
# File 'lib/ask/agent/session.rb', line 800 def @chat. @messages = [] end |
#rollback!(seq: nil, turn: nil) ⇒ self
Rewind the session to an earlier checkpoint: messages and turn count are restored from the snapshot, and the store's head moves back. Later checkpoints are kept, so the session can roll forward again.
518 519 520 521 522 523 524 525 526 527 528 529 530 |
# File 'lib/ask/agent/session.rb', line 518 def rollback!(seq: nil, turn: nil) require_checkpoints! raise "cannot roll back a running session" if @running seq = resolve_checkpoint_seq(seq, turn) data = load_checkpoint(seq: seq) raise ArgumentError, "no checkpoint #{seq}" unless data @checkpoint_store.rollback(@id, seq) restore_from_snapshot(data) emit(Events::SessionRolledBack.new(session_id: @id, seq: seq, turn_count: @turn_count)) self end |
#run(message, tools: nil, reset: true, attachments: nil) ⇒ Object
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/ask/agent/session.rb', line 215 def run(, tools: nil, reset: true, attachments: nil) raise "Session deleted" if @deleted raise "Session already running" if @running @running = true @abort_requested = false Ask::Agent.current_session = self if reset @turn_count = 0 @loop.reset! end emit(Events::SessionStart.new) active_tools = @tools # Retrieve relevant memories from previous sessions into context. inject_memories() if reset && @memory if active_tools.empty? && !@_no_tools_instructed @chat.(role: :system, content: "You have no tools available. Do not claim you can look up information or use tools of any kind. Just respond based on your existing knowledge.") @_no_tools_instructed = true end # Leftover queued steers from a previous run become user messages # before this run starts. drain_leftover_steers begin @tool_executor.telemetry = @telemetry response = @loop.run_turn( chat: @chat, message: , attachments: , tools: active_tools, tool_executor: @tool_executor, compactor: @compactor, hooks: @hooks, event_emitter: self, session_id: @id, tool_call_repair: @tool_call_repair, steer_source: method(:drain_one_steer), persist: @state ? method(:persist!) : nil ) @total_input_tokens += @loop.last_input_tokens.to_i @total_output_tokens += @loop.last_output_tokens.to_i @total_cost += @loop.last_cost.to_f rescue MaxTurnsExceeded => e emit(Events::MaxTurnsExceeded.new(max_turns: @max_turns)) @telemetry.log(:max_turns_exceeded, session_id: @id, max_turns: @max_turns) response = last_content rescue LoopDetected => e emit(Events::LoopDetected.new(tool_name: e., repeated_count: 3)) @telemetry.log(:loop_detected, session_id: @id, tool_name: e., repeated_count: 3) response = last_content rescue Ask::ContextLengthExceeded if @compactor && !@compactor.overflow_recovered? @compactor.recover_from_overflow retry end response = "I'm sorry, the conversation has grown too long. Please start a new session." rescue StandardError => e emit(Events::Error.new(error: e., recoverable: true)) raise ensure @running = false Ask::Agent.current_session = nil if Ask::Agent.current_session.equal?(self) persist! if @state # Learn from this session: extract durable facts into memory. # Only on the initial run (not follow-ups); best-effort, never # raises. extract_memories if reset && @memory_learning # A pending tool completed while this run was busy: voice the # result now that the turn is over (one follow-up per completion). follow_up = @pending_mutex.synchronize do take = @followup_pending @followup_pending = false take end run_follow_up if follow_up end @tool_calls_made = @tool_executor.total_executions # Independent evaluator step (generator/evaluator separation). # Runs BEFORE self-reflection so the evaluator gets a fresh, unbiased look # at the generator's output using a separate model and isolated context. @skip_reflector = false if @evaluator && !@abort_requested goal = @evaluator_config[:goal] || eval_result = @evaluator.evaluate( goal: goal.to_s, response: response, event_emitter: self ) @telemetry.log(:evaluation_end, session_id: @id, decision: eval_result.decision, feedback: eval_result.feedback, scores: eval_result.scores) case eval_result.decision when :revise @chat.( role: :system, content: "An independent evaluator has requested revisions:\n\n#{eval_result.feedback}" ) response = @loop.run_turn( chat: @chat, message: "", tools: active_tools, tool_executor: @tool_executor, compactor: @compactor, hooks: @hooks, event_emitter: self, session_id: @id, tool_call_repair: @tool_call_repair ) @total_input_tokens += @loop.last_input_tokens.to_i @total_output_tokens += @loop.last_output_tokens.to_i @total_cost += @loop.last_cost.to_f # Skip reflector — we already iterated based on evaluator feedback @skip_reflector = true when :block emit(Events::EvaluationBlocked.new( feedback: eval_result.feedback, scores: eval_result.scores, evidence: eval_result.evidence )) response = "This response was blocked by the evaluator: #{eval_result.feedback}" when :accept # Fall through to reflector for backward compatibility end end if @reflector && !@skip_reflector && @reflector.reflect?(@tool_calls_made) && !@abort_requested eval_result = @reflector.evaluate(response: response, event_emitter: self) @telemetry.log(:reflection_end, session_id: @id, decision: eval_result[:decision], feedback: eval_result[:feedback]) if eval_result[:decision] == :improve && !@abort_requested @chat.( role: :system, content: "Improve your last response: #{eval_result[:feedback]}" ) response = @loop.run_turn( chat: @chat, message: "", tools: active_tools, tool_executor: @tool_executor, compactor: @compactor, hooks: @hooks, event_emitter: self, session_id: @id, tool_call_repair: @tool_call_repair ) @total_input_tokens += @loop.last_input_tokens.to_i @total_output_tokens += @loop.last_output_tokens.to_i @total_cost += @loop.last_cost.to_f end end if @meta_agent_config @telemetry.increment_session_count! end # Capture messages before emitting SessionEnd so event handlers # can access agent.messages during the callback @messages = @chat..dup emit(Events::SessionEnd.new( result: response, turn_count: @turn_count, tool_calls_made: @tool_calls_made, input_tokens: @total_input_tokens, output_tokens: @total_output_tokens, cost: @total_cost )) response end |
#run_follow_up ⇒ Object
A follow-up turn driven by an async completion: runs the loop with the tool message already in the conversation, preserving turn state.
771 772 773 774 775 776 |
# File 'lib/ask/agent/session.rb', line 771 def run_follow_up run("", reset: false) rescue => e emit(Events::Error.new(error: e., recoverable: false)) raise end |
#running? ⇒ Boolean
423 |
# File 'lib/ask/agent/session.rb', line 423 def running? = @running |
#save ⇒ Object
426 427 428 |
# File 'lib/ask/agent/session.rb', line 426 def save persist! if @state end |
#skill(name) ⇒ Object
Load a skill by name or file path. Injects the skill's full instructions into the conversation as a system message.
783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 |
# File 'lib/ask/agent/session.rb', line 783 def skill(name) if @skills_registry && (s = @skills_registry[name]) @chat.( role: :system, content: "## Skill: #{s.name}\n\n#{s.description}\n\n---\n\n#{s.instructions}" ) elsif File.exist?(name.to_s) content = File.read(name.to_s) @chat.( role: :system, content: "## Skill: #{name}\n\n---\n\n#{content}" ) else raise Ask::Skills::Error, "Skill not found: #{name.inspect}" end end |
#steer(message, expected_turn_id: nil, attachments: nil) ⇒ Hash
Inject a message into the session safely, from any thread (web, CLI, another agent):
- :stale — the caller's
expected_turn_iddoes not match the current turn id (the caller was looking at an older state). - :queued — a turn is running; the message is held and dispatched as the next user message at the next turn boundary.
- :steered — the session is idle; the message is added to the conversation and processed by the next run.
686 687 688 689 690 691 692 693 694 695 696 697 698 |
# File 'lib/ask/agent/session.rb', line 686 def steer(, expected_turn_id: nil, attachments: nil) @steer_mutex.synchronize do if expected_turn_id && expected_turn_id != @turn_id return { status: :stale, turn_id: @turn_id } end if @running @queued_steers << .to_s return { status: :queued, turn_id: @turn_id } end end @chat.(role: :user, content: .to_s, attachments: ) { status: :steered, turn_id: @turn_id } end |