Class: Ask::Agent::Session
- Inherits:
-
Object
- Object
- Ask::Agent::Session
- Includes:
- Test::SessionOverride
- Defined in:
- lib/ask/agent/session.rb
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.
- .load(id, adapter:) ⇒ Object
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).
- #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) ⇒ 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) ⇒ 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.
20 21 22 23 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 |
# File 'lib/ask/agent/session.rb', line 20 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 = {} @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) } ) 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.
182 183 184 |
# File 'lib/ask/agent/session.rb', line 182 def approval_queue @approval_queue end |
#artifact_store ⇒ Ask::Agent::ArtifactStore? (readonly)
Returns store for tool deliverables
(only when the artifacts: option is enabled).
197 198 199 |
# File 'lib/ask/agent/session.rb', line 197 def artifact_store @artifact_store end |
#chat ⇒ Object (readonly)
Returns the value of attribute chat.
9 10 11 |
# File 'lib/ask/agent/session.rb', line 9 def chat @chat end |
#created_at ⇒ Object (readonly)
Returns the value of attribute created_at.
9 10 11 |
# File 'lib/ask/agent/session.rb', line 9 def created_at @created_at end |
#id ⇒ Object (readonly)
Returns the value of attribute id.
9 10 11 |
# File 'lib/ask/agent/session.rb', line 9 def id @id end |
#memory ⇒ Ask::Agent::Memory? (readonly)
Returns durable memory (only when passed
via the memory: option).
191 192 193 |
# File 'lib/ask/agent/session.rb', line 191 def memory @memory end |
#messages ⇒ Object (readonly)
Returns the value of attribute messages.
9 10 11 |
# File 'lib/ask/agent/session.rb', line 9 def @messages end |
#meta_agent_results ⇒ Object (readonly)
Returns the value of attribute meta_agent_results.
16 17 18 |
# File 'lib/ask/agent/session.rb', line 16 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).
194 195 196 |
# File 'lib/ask/agent/session.rb', line 194 def output_store @output_store end |
#plan_queue ⇒ Ask::Agent::ApprovalQueue? (readonly)
Returns queue carrying plan approvals (only when plan mode is enabled).
185 186 187 |
# File 'lib/ask/agent/session.rb', line 185 def plan_queue @plan_queue end |
#skills_registry ⇒ Ask::Skills::Registry? (readonly)
Returns auto-discovered skills registry.
18 19 20 |
# File 'lib/ask/agent/session.rb', line 18 def skills_registry @skills_registry end |
#todo_list ⇒ Ask::Agent::TodoList? (readonly)
Returns session task list (only when todos are enabled).
188 189 190 |
# File 'lib/ask/agent/session.rb', line 188 def todo_list @todo_list end |
#tool_calls_made ⇒ Object (readonly)
Returns the value of attribute tool_calls_made.
10 11 12 |
# File 'lib/ask/agent/session.rb', line 10 def tool_calls_made @tool_calls_made end |
#tools ⇒ Object (readonly)
Returns the value of attribute tools.
9 10 11 |
# File 'lib/ask/agent/session.rb', line 9 def tools @tools end |
#total_cost ⇒ Object (readonly)
Returns the value of attribute total_cost.
10 11 12 |
# File 'lib/ask/agent/session.rb', line 10 def total_cost @total_cost end |
#total_input_tokens ⇒ Object (readonly)
Returns the value of attribute total_input_tokens.
10 11 12 |
# File 'lib/ask/agent/session.rb', line 10 def total_input_tokens @total_input_tokens end |
#total_output_tokens ⇒ Object (readonly)
Returns the value of attribute total_output_tokens.
10 11 12 |
# File 'lib/ask/agent/session.rb', line 10 def total_output_tokens @total_output_tokens end |
#turn_count ⇒ Object (readonly)
Returns the value of attribute turn_count.
9 10 11 |
# File 'lib/ask/agent/session.rb', line 9 def turn_count @turn_count end |
#turn_id ⇒ Integer (readonly)
Returns id of the turn currently running (or the last completed turn when idle).
650 651 652 |
# File 'lib/ask/agent/session.rb', line 650 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.
1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 |
# File 'lib/ask/agent/session.rb', line 1064 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 |
.load(id, adapter:) ⇒ Object
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 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 |
# File 'lib/ask/agent/session.rb', line 413 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: 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 |
Instance Method Details
#abort ⇒ Object
464 465 466 |
# File 'lib/ask/agent/session.rb', line 464 def abort @abort_requested = true end |
#abort_requested? ⇒ Boolean
468 |
# File 'lib/ask/agent/session.rb', line 468 def abort_requested? = @abort_requested |
#approve_plan(action) ⇒ Object
617 618 619 620 621 622 623 624 625 626 627 628 629 630 |
# File 'lib/ask/agent/session.rb', line 617 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.
552 553 554 555 |
# File 'lib/ask/agent/session.rb', line 552 def artifacts require_artifacts! @artifact_store.list(@id) end |
#checkpoint_history ⇒ Array<Integer>
Returns checkpoint seqs, oldest first.
474 475 476 477 |
# File 'lib/ask/agent/session.rb', line 474 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.
712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 |
# File 'lib/ask/agent/session.rb', line 712 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 @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
456 457 458 459 460 461 462 |
# File 'lib/ask/agent/session.rb', line 456 def delete @deleted = true @checkpoint_store&.delete(@id) @output_store&.delete(@id) @artifact_store&.delete(@id) @state&.delete(@id) end |
#deleted? ⇒ Boolean
407 |
# File 'lib/ask/agent/session.rb', line 407 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).
575 576 577 578 579 |
# File 'lib/ask/agent/session.rb', line 575 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.
569 570 571 |
# File 'lib/ask/agent/session.rb', line 569 def drain_one_steer @steer_mutex.synchronize { @queued_steers.shift }.to_s end |
#emit(event) ⇒ Object
400 401 402 403 404 |
# File 'lib/ask/agent/session.rb', line 400 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.
584 585 586 587 588 589 |
# File 'lib/ask/agent/session.rb', line 584 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).
560 561 562 563 |
# File 'lib/ask/agent/session.rb', line 560 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.
525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 |
# File 'lib/ask/agent/session.rb', line 525 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.
594 595 596 597 598 599 600 601 602 |
# File 'lib/ask/agent/session.rb', line 594 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.
484 485 486 487 488 |
# File 'lib/ask/agent/session.rb', line 484 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
394 395 396 397 398 |
# File 'lib/ask/agent/session.rb', line 394 def on(type, &block) @event_handlers[type] ||= [] @event_handlers[type] << block self end |
#on_event(&block) ⇒ Object
389 390 391 392 |
# File 'lib/ask/agent/session.rb', line 389 def on_event(&block) @event_handlers[:all] << block self end |
#pending_tools? ⇒ Boolean
Returns true while at least one async tool is running.
699 700 701 |
# File 'lib/ask/agent/session.rb', line 699 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).
606 |
# File 'lib/ask/agent/session.rb', line 606 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.
610 611 612 613 614 615 |
# File 'lib/ask/agent/session.rb', line 610 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.
681 682 683 |
# File 'lib/ask/agent/session.rb', line 681 def queued_steers @steer_mutex.synchronize { @queued_steers.size } end |
#reflection_count ⇒ Object
12 13 14 |
# File 'lib/ask/agent/session.rb', line 12 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). The background work completes later via #complete_pending_tool.
690 691 692 693 694 695 696 |
# File 'lib/ask/agent/session.rb', line 690 def register_pending_tool(tool_call_id, result) @pending_mutex.synchronize do @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
632 633 634 635 636 637 638 639 640 641 642 643 644 |
# File 'lib/ask/agent/session.rb', line 632 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
765 766 767 768 |
# File 'lib/ask/agent/session.rb', line 765 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.
501 502 503 504 505 506 507 508 509 510 511 512 513 |
# File 'lib/ask/agent/session.rb', line 501 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) ⇒ Object
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 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 |
# File 'lib/ask/agent/session.rb', line 199 def run(, tools: nil, reset: true) 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: , 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.
736 737 738 739 740 741 |
# File 'lib/ask/agent/session.rb', line 736 def run_follow_up run("", reset: false) rescue => e emit(Events::Error.new(error: e., recoverable: false)) raise end |
#running? ⇒ Boolean
406 |
# File 'lib/ask/agent/session.rb', line 406 def running? = @running |
#save ⇒ Object
409 410 411 |
# File 'lib/ask/agent/session.rb', line 409 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.
748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 |
# File 'lib/ask/agent/session.rb', line 748 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) ⇒ 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.
666 667 668 669 670 671 672 673 674 675 676 677 678 |
# File 'lib/ask/agent/session.rb', line 666 def steer(, expected_turn_id: 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) { status: :steered, turn_id: @turn_id } end |