Repository navigation
Split agent instructions into a primer and topic guides #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
499fec8
2a40894
5f0e7fe
e5c7d4a
891a859
5f18b91
fc39678
281a58b
c15cbcc
a23571e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,7 @@ require "uri" | |
| require "optparse" | ||
| require "open3" | ||
| require "shellwords" | ||
| require "set" | ||
|
|
||
| $stdout.sync = true | ||
|
|
||
|
|
@@ -145,6 +146,12 @@ def request(method, path, body: nil, params: nil, read_timeout: 90) | |
| req.body = body.to_json if body | ||
| Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: read_timeout) do |http| | ||
| res = http.request(req) | ||
| # A rejected token never recovers on retry, and every call after it | ||
| # would fail the same way. Stop so a supervisor (or a person) notices, | ||
| # instead of polling the server in a tight loop. | ||
| if res.code == "401" | ||
| abort "coplan-bridge: #{method.upcase} #{path} -> 401. The token is invalid, expired, or revoked. Mint a new token and restart the bridge." | ||
| end | ||
| warn "coplan-bridge: #{method.upcase} #{path} -> #{res.code}" unless res.code.start_with?("2") | ||
| res.body && !res.body.empty? ? JSON.parse(res.body) : {} | ||
| end | ||
|
|
@@ -168,7 +175,8 @@ def wake_prompt(event) | |
| #{payload["comment_author"] ? "#{payload["comment_author"]} wrote: #{payload["comment_body"].to_s.strip}" : ""} | ||
| #{payload["anchor_text"] ? "Anchored to: \"#{payload["anchor_text"]}\"" : ""} | ||
|
|
||
| Respond as the plan's agent, over the CoPlan API at #{BASE} (full instructions: #{BASE}/agent-instructions): | ||
| Respond as the plan's agent, over the CoPlan API at #{BASE} (full instructions: #{BASE}/agent-instructions). | ||
| Authenticate every call with `Authorization: Bearer $TOKEN`: $TOKEN in your environment is this bridge's session token. | ||
| 1. PATCH /api/v1/plans/#{event["plan_id"]}/agent_session {"state":"active","detail":"<what you're doing>"} immediately. | ||
| 2. Reply on the thread first (narrate what you'll change), then apply any edit with PUT .../content. | ||
| 3. Finish with {"state":"complete"} (or "awaiting_input" if you asked a question). | ||
|
|
@@ -237,7 +245,9 @@ class AcpAgent | |
|
|
||
| def boot | ||
| # stderr passes through so the agent's own logging stays visible. | ||
| @stdin, @stdout, @wait_thread = Open3.popen2(*@command) | ||
| # Same token handoff as the exec adapters: the prompt tells the agent | ||
| # to authenticate with $TOKEN, so it has to be this bridge's token. | ||
| @stdin, @stdout, @wait_thread = Open3.popen2({ "TOKEN" => TOKEN, "COPLAN_TOKEN" => TOKEN }, *@command) | ||
| rpc("initialize", { | ||
| protocolVersion: PROTOCOL_VERSION, | ||
| # No fs or terminal capabilities: the agent works through its own | ||
|
|
@@ -397,32 +407,75 @@ def dispatch(event) | |
| # Honest pill before the harness even boots. | ||
| Bridge.patch_state(event["plan_id"], "active", "reading your comment") | ||
|
|
||
| # A failed turn may still have replied or edited before it exited, and | ||
| # redelivery hands the same event to the agent again. Say so, the way | ||
| # the ACP restart path does, so the retry checks before acting. | ||
| prompt = wake_prompt(event) | ||
| if ATTEMPTED.include?(event["id"]) | ||
| prompt = "NOTE: an earlier attempt at this event failed and may have partially completed. " \ | ||
| "Check the plan's thread and content first, and do not duplicate replies or edits already made.\n\n#{prompt}" | ||
| end | ||
| ATTEMPTED << event["id"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an exec-adapter bridge that runs continuously, every dispatched event ID is added to Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 891a859: an id is removed from |
||
|
|
||
| cmd = template.map do |part| | ||
| part.gsub("{session}", CONFIG.fetch("harness_session", "coplan")).gsub("{prompt}", wake_prompt(event)) | ||
| part.gsub("{session}", CONFIG.fetch("harness_session", "coplan")).gsub("{prompt}", prompt) | ||
| end | ||
| puts "coplan-bridge: dispatching #{event["type"]} -> #{adapter}" | ||
| system(*cmd) | ||
| # system returns false on a nonzero exit and nil when the command can't | ||
| # start; neither raises. Raise here so the loop leaves the event unacked | ||
| # for redelivery instead of acking a turn that never happened. | ||
| # The agent acts with the bridge's session token, under the name the | ||
| # instructions use for it. Its replies and edits are then attributed to | ||
| # that token, and never echoed back to this bridge's inbox. | ||
| return if system({ "TOKEN" => TOKEN, "COPLAN_TOKEN" => TOKEN }, *cmd) | ||
|
|
||
| raise "#{adapter} adapter #{$?&.exitstatus ? "exited #{$?.exitstatus}" : "could not start"}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a resumed CLI performs API side effects and then exits nonzero—for example, after posting a reply but during teardown—this new raise redelivers the same event with the original prompt, so the next turn can post the reply or edit again. The ACP restart path already adds a recovery instruction to inspect existing work, but exec-adapter retries do not; add equivalent recovery/deduplication context before redispatching a previously attempted event. Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in e5c7d4a: the bridge remembers which event ids it has handed to an exec adapter. On redelivery it prepends the same recovery note the ACP path uses (check the thread and content, do not duplicate replies or edits). Checked against a local server with an adapter that fails once: attempt 1 got the plain prompt, attempt 2 began with the NOTE, and the event was acked after attempt 2 succeeded. |
||
| end | ||
|
|
||
| # Event ids handed to an exec adapter but not yet acked, so a redelivered | ||
| # event can be marked as a retry. An id leaves the set when its ack | ||
| # succeeds, so the set holds only events still in flight. | ||
| ATTEMPTED = Set.new | ||
|
|
||
| claim_sessions | ||
| cursor = nil | ||
| failures = 0 | ||
| puts "coplan-bridge: draining events from #{BASE}" | ||
| loop do | ||
| resp = request(:get, "/api/v1/agent/events", params: { wait: 25, cursor: cursor }.compact) | ||
| events = resp["events"] || [] | ||
| # A good poll always carries `events`. Anything else (a 5xx, an HTML | ||
| # error page) returned immediately, so wait before asking again. So did | ||
| # a throttled long-poll: the server had no thread to hold for us. | ||
| unless resp.key?("events") | ||
| sleep 5 | ||
| next | ||
| end | ||
| sleep 3 if resp["throttled"] | ||
| events = resp["events"] | ||
| events.each do |event| | ||
| dispatch(event) | ||
| # Ack each event only once its dispatch came back — at-least-once | ||
| # means a harness failure leaves the event unacked for redelivery, | ||
| # never swallowed by a batch-level ack. | ||
| cursor = event["id"] | ||
| request(:post, "/api/v1/agent/events/ack", body: { cursor: cursor }) | ||
| ATTEMPTED.delete(event["id"]) | ||
| failures = 0 | ||
| rescue => e | ||
| # Stop the batch here: acking later events would also ack this one | ||
| # (the cursor is a high-water mark). The next poll redelivers | ||
| # everything unacked; the sleep keeps a wedged adapter from spinning. | ||
| warn "coplan-bridge: dispatch failed: #{e.class}: #{e.message} — leaving event unacked" | ||
| sleep 3 | ||
| # everything unacked. Dispatch already said `active`; take that back, | ||
| # or every retry would refresh a pill promising a turn that isn't | ||
| # running. Back off so a wedged adapter doesn't spin. | ||
| failures += 1 | ||
| delay = [ 3 * 2**(failures - 1), 60 ].min | ||
| warn "coplan-bridge: dispatch failed: #{e.class}: #{e.message} — leaving event unacked, retrying in #{delay}s" | ||
| begin | ||
| Bridge.patch_state(event["plan_id"], "complete", nil) | ||
| rescue StandardError | ||
| nil # the server may be the thing that failed; the retry will tell | ||
| end | ||
| sleep delay | ||
| break | ||
| end | ||
| rescue Interrupt | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <% title = @instructions_markdown[/\A# (.+)$/, 1] || "Agent guide" %> | ||
| <% content_for :title, "#{title} — CoPlan" %> | ||
|
|
||
| <div class="agent-reference"> | ||
| <%= link_to "← Agent instructions", agent_instructions_reference_path, class: "agent-reference__back" %> | ||
| <header class="agent-reference__header"> | ||
| <p> | ||
| A topic guide that your agent fetches when a task needs it. Agents get this URL as raw Markdown; | ||
| you can also <%= link_to "view the raw markdown", @raw_path %>. | ||
| </p> | ||
| <%= render "coplan/shared/copy_url", url: @instructions_url, button_class: "btn btn--secondary" %> | ||
| </header> | ||
|
|
||
| <article class="agent-reference__doc"> | ||
| <%= render_markdown(@instructions_markdown, interactive: false) %> | ||
| </article> | ||
| </div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$TOKENinto ACP subprocessesWhen the documented
--acpmode is used, the install flow exports onlyCOPLAN_TOKEN, whileAcpAgent#bootstarts the ACP command withOpen3.popen2(*@command)and never creates the$TOKENalias that the exec-adapter branch supplies. This prompt therefore tells the ACP agent to authenticate with an unset or stale variable, so its API calls fail even though the bridge itself can poll successfully; inject both token variables into the ACP subprocess or direct ACP prompts to the exported variable.AGENTS.md reference: AGENTS.md:L162-L168
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in a23571e:
AcpAgent#bootnow starts the ACP command withOpen3.popen2({ "TOKEN" => TOKEN, "COPLAN_TOKEN" => TOKEN }, *@command), matching the exec adapters, so the prompt's$TOKENis the bridge token.