Class: Ask::CodingHarness::AgentRunner

Inherits:
Object
  • Object
show all
Defined in:
lib/ask/coding_harness/agent_runner.rb

Overview

Runs coding-agent turns for conversations: maps each conversation to an adapter session, streams translated events, and persists assistant messages back into the store.

The default runtime is ask-agent (via the ask_agent adapter). Other coding agents (Codex, Claude Code, ACP-based agents) plug in through ask-coding-providers by setting ACH_ADAPTER/CODING_PROVIDER — the runner only needs the adapter's duck-typed surface: create_session, send_and_stream, and (when supported) approve_action, reject_action, approve_all, approve_plan, reject_plan, abort.

Constant Summary collapse

TOOL_CLASSES =

Tool name → ask-tools-shell class, for the ask_agent runtime.

{
  "bash" => Ask::Tools::Bash,
  "read" => Ask::Tools::Read,
  "write" => Ask::Tools::Write,
  "edit" => Ask::Tools::Edit,
  "glob" => Ask::Tools::Glob,
  "grep" => Ask::Tools::Grep,
  "code" => Ask::Tools::Code,
  "apply_patch" => Ask::Tools::ApplyPatch,
  "repl" => Ask::Tools::Repl
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(config:, store:, translator: EventTranslator.new, tool_classes: TOOL_CLASSES) ⇒ AgentRunner

Returns a new instance of AgentRunner.

Parameters:

  • config (Config)

    harness configuration

  • store (Store)

    conversation persistence

  • translator (EventTranslator) (defaults to: EventTranslator.new)

    adapter event → SSE event

  • tool_classes (Hash{String => Class}) (defaults to: TOOL_CLASSES)

    tool name → class map; overridable so hosts can register custom tools



36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/ask/coding_harness/agent_runner.rb', line 36

def initialize(config:, store:, translator: EventTranslator.new, tool_classes: TOOL_CLASSES)
  @config = config
  @store = store
  @translator = translator
  @tool_classes = tool_classes
  @adapter = nil
  @mutex = Mutex.new
  @adapter_mutex = Mutex.new
  @turn_mutex = Mutex.new
  @sessions = {}
  @turns = {}
end

Instance Method Details

#abort(conversation_id) ⇒ Object



104
105
106
# File 'lib/ask/coding_harness/agent_runner.rb', line 104

def abort(conversation_id)
  send_adapter(conversation_id, :abort)
end

#adapterObject

The adapter (created lazily on first use). Uses its own mutex so callers holding @mutex (session_for, stop) can still build it.



51
52
53
# File 'lib/ask/coding_harness/agent_runner.rb', line 51

def adapter
  @adapter_mutex.synchronize { @adapter ||= build_adapter }
end

#agent_definitions(workspace) ⇒ Object

Declarative agents in a workspace (ask-agent convention): [instructions:] discovered without loading the definitions. Safe for listing in the UI.



230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/ask/coding_harness/agent_runner.rb', line 230

def agent_definitions(workspace)
  Dir.chdir(workspace) do
    Ask::Agent.default_agent_paths.filter_map do |base|
      next unless File.directory?(base)
      Dir["#{base}/*/agent.rb"].sort.filter_map do |file|
        dir = File.dirname(file)
        name = File.basename(dir)
        next if name == "shared"
        instructions = File.file?(File.join(dir, "instructions.md")) ? File.read(File.join(dir, "instructions.md")) : nil
        { "name" => name, "instructions" => instructions }
      end
    end.flatten
  end
end

#agent_instructions(workspace, agent) ⇒ Object

The instructions.md content of a named agent, loaded through the ask-agent registry (which also validates the definition).



247
248
249
250
251
252
253
# File 'lib/ask/coding_harness/agent_runner.rb', line 247

def agent_instructions(workspace, agent)
  Dir.chdir(workspace) do
    Ask::Agent.rediscover!
    klass = Ask::Agent.definitions[agent.to_s]&.first
    klass&.instructions_content
  end
end

#approve(conversation_id, action_id) ⇒ Object

── Approval / plan controls (no-ops when the adapter lacks them) ──



84
85
86
# File 'lib/ask/coding_harness/agent_runner.rb', line 84

def approve(conversation_id, action_id)
  send_adapter(conversation_id, :approve_action, action_id)
end

#approve_all(conversation_id) ⇒ Object



92
93
94
# File 'lib/ask/coding_harness/agent_runner.rb', line 92

def approve_all(conversation_id)
  send_adapter(conversation_id, :approve_all)
end

#approve_plan(conversation_id) ⇒ Object



96
97
98
# File 'lib/ask/coding_harness/agent_runner.rb', line 96

def approve_plan(conversation_id)
  send_adapter(conversation_id, :approve_plan)
end

#pending_approvals(conversation_id) ⇒ Object



108
109
110
111
112
113
114
115
116
# File 'lib/ask/coding_harness/agent_runner.rb', line 108

def pending_approvals(conversation_id)
  sid = @mutex.synchronize { @sessions[conversation_id] }
  return [] unless sid
  if adapter.respond_to?(:pending_approvals)
    adapter.pending_approvals(sid)
  else
    []
  end
end

#reject(conversation_id, action_id) ⇒ Object



88
89
90
# File 'lib/ask/coding_harness/agent_runner.rb', line 88

def reject(conversation_id, action_id)
  send_adapter(conversation_id, :reject_action, action_id)
end

#reject_plan(conversation_id) ⇒ Object



100
101
102
# File 'lib/ask/coding_harness/agent_runner.rb', line 100

def reject_plan(conversation_id)
  send_adapter(conversation_id, :reject_plan)
end

#running?(conversation_id) ⇒ Boolean

Returns:

  • (Boolean)


77
78
79
80
# File 'lib/ask/coding_harness/agent_runner.rb', line 77

def running?(conversation_id)
  thread = @mutex.synchronize { @turns[conversation_id] }
  thread&.alive? ? true : false
end

#start_turn(conversation, prompt, model: nil) {|Hash| ... } ⇒ Thread

Run a turn for a conversation, streaming harness events to the block. Runs in a background thread; returns the thread.

Parameters:

  • conversation (Hash)

    conversation record (from the store)

  • prompt (String)

    the user message

  • model (String, nil) (defaults to: nil)

    model override

Yields:

  • (Hash)

    harness events ({ type:, data: })

Returns:

  • (Thread)

Raises:

  • (ArgumentError)


63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/ask/coding_harness/agent_runner.rb', line 63

def start_turn(conversation, prompt, model: nil, &on_event)
  raise ArgumentError, "prompt must not be empty" if prompt.to_s.strip.empty?

  thread = Thread.new do
    begin
      run_turn(conversation, prompt, model: model, &on_event)
    rescue => e
      emit(on_event, { type: "error", data: { error: e.message } })
    end
  end
  @mutex.synchronize { @turns[conversation["id"]] = thread }
  thread
end

#stopObject



118
119
120
121
122
123
124
125
126
# File 'lib/ask/coding_harness/agent_runner.rb', line 118

def stop
  @mutex.synchronize do
    @turns.each_value { |t| t.kill if t.alive? }
    @turns.clear
    @sessions.clear
    @adapter&.stop if @adapter.respond_to?(:stop)
    @adapter = nil
  end
end

#system_prompt_for(workspace, agent = nil) ⇒ Object

The system prompt for a workspace. When a declarative agent is selected, its instructions.md become the custom base prompt (project context, guidelines, append, and footer still apply). Adapters that don't accept a per-session prompt (external ACP agents) ignore the extra keywords.



133
134
135
136
137
138
139
140
# File 'lib/ask/coding_harness/agent_runner.rb', line 133

def system_prompt_for(workspace, agent = nil)
  Ask::CodingHarness::SystemPrompt.build(
    workspace: workspace,
    custom: @config.system_prompt || (agent_instructions(workspace, agent) if agent),
    append: @config.system_prompt_append,
    guidelines: @config.system_prompt_guidelines
  )
end