Agents ask each other, hand each other work, and answer
An agent asks the agent on another pull request a question or hands it work with message_agent, naming its own pull request; the other receives it at its next step and replies with answer_message, which reaches the asker at its next step. If the agent asked is not at work, the asker is told straight away and pointed at the change to read. Agents are told their own pull request's number. Exchanges show on the outcome page with where they stand, and on both pull requests.
13 files+481−320/13 viewed
| 53 | 53 | UpdateRepoSettings, | |
| 54 | 54 | GetMergeQueue, | |
| 55 | 55 | MessageAgent, | |
| 56 | + | AnswerMessage, | |
| 56 | 57 | TakeMessages, | |
| 57 | 58 | ListIssues, | |
| 58 | 59 | GetIssue, | |
| 187 | 188 | } | |
| 188 | 189 | ||
| 189 | 190 | impl Op { | |
| 190 | − | pub const ALL: [Op; 34] = [ | |
| 191 | + | pub const ALL: [Op; 35] = [ | |
| 191 | 192 | Op::Whoami, | |
| 192 | 193 | Op::CreateWorkspace, | |
| 193 | 194 | Op::ListRepos, | |
| 198 | 199 | Op::UpdateRepoSettings, | |
| 199 | 200 | Op::GetMergeQueue, | |
| 200 | 201 | Op::MessageAgent, | |
| 202 | + | Op::AnswerMessage, | |
| 201 | 203 | Op::TakeMessages, | |
| 202 | 204 | Op::ListIssues, | |
| 203 | 205 | Op::GetIssue, | |
| 240 | 242 | Op::GetRepoSettings => "get_repo_settings", | |
| 241 | 243 | Op::GetMergeQueue => "get_merge_queue", | |
| 242 | 244 | Op::MessageAgent => "message_agent", | |
| 245 | + | Op::AnswerMessage => "answer_message", | |
| 243 | 246 | Op::TakeMessages => "take_messages", | |
| 244 | 247 | Op::UpdateRepoSettings => "update_repo_settings", | |
| 245 | 248 | Op::ListIssues => "list_issues", | |
| 288 | 291 | "Change how a repository handles pull requests. Only the fields given are changed. Members of its workspace only." | |
| 289 | 292 | } | |
| 290 | 293 | Op::MessageAgent => { | |
| 291 | − | "Send the agent working on a pull request a message: a correction, a hint, a change of plan. It receives it at its next step, and it is recorded in the pull request's session. The pull request's author and members of its workspace only." | |
| 294 | + | "Send the agent working on a pull request a message: a correction, a hint, a change of plan. It receives it at its next step, and it is recorded in the pull request's session. The pull request's author and members of its workspace only. An agent uses it to ask the agent on another pull request a question (kind: question) or hand it work that belongs there (kind: handoff), giving its own pull request as from_number; the answer comes back to it at its next step." | |
| 295 | + | } | |
| 296 | + | Op::AnswerMessage => { | |
| 297 | + | "Answer a question or a handoff another agent sent you, by the message's id. For a handoff, set decline to say it is not yours to take. The answer reaches the asking agent at its next step." | |
| 292 | 298 | } | |
| 293 | 299 | Op::TakeMessages => { | |
| 294 | 300 | "For a g1t agent at work: the messages people have sent it that it has not seen yet. Each is returned once." | |
| 402 | 408 | Op::MessageAgent => object( | |
| 403 | 409 | numbered(json!({ | |
| 404 | 410 | "body": { "type": "string", "description": "What to tell the agent." }, | |
| 411 | + | "kind": { | |
| 412 | + | "type": "string", | |
| 413 | + | "enum": ["question", "handoff"], | |
| 414 | + | "description": "For an agent: a question, or work handed over.", | |
| 415 | + | }, | |
| 416 | + | "from_number": { | |
| 417 | + | "type": "integer", | |
| 418 | + | "description": "For an agent: the pull request you are working on, where the answer goes.", | |
| 419 | + | }, | |
| 405 | 420 | })), | |
| 406 | 421 | &["repo", "number", "body"], | |
| 407 | 422 | ), | |
| 423 | + | Op::AnswerMessage => object( | |
| 424 | + | json!({ | |
| 425 | + | "repo": repo_schema(), | |
| 426 | + | "id": { "type": "string", "description": "The message's id, as it was given to you." }, | |
| 427 | + | "body": { "type": "string", "description": "Your answer." }, | |
| 428 | + | "decline": { "type": "boolean", "description": "For a handoff: it is not yours to take." }, | |
| 429 | + | }), | |
| 430 | + | &["repo", "id", "body"], | |
| 431 | + | ), | |
| 408 | 432 | Op::TakeMessages => object(numbered(json!({})), &["repo", "number"]), | |
| 409 | 433 | Op::UpdateRepoSettings => object( | |
| 410 | 434 | json!({ | |
| 837 | 861 | pass( | |
| 838 | 862 | work, | |
| 839 | 863 | "message_agent", | |
| 840 | − | &json!({ "actor": actor(), "repo": repo, "number": number, "body": text(input, "body") }), | |
| 864 | + | &json!({ | |
| 865 | + | "actor": actor(), | |
| 866 | + | "repo": repo, | |
| 867 | + | "number": number, | |
| 868 | + | "body": text(input, "body"), | |
| 869 | + | "kind": input["kind"].as_str(), | |
| 870 | + | "from_number": integer(input, "from_number"), | |
| 871 | + | }), | |
| 872 | + | ) | |
| 873 | + | .await | |
| 874 | + | } | |
| 875 | + | Op::AnswerMessage => { | |
| 876 | + | pass( | |
| 877 | + | work, | |
| 878 | + | "answer_message", | |
| 879 | + | &json!({ | |
| 880 | + | "actor": actor(), | |
| 881 | + | "repo": repo, | |
| 882 | + | "id": text(input, "id"), | |
| 883 | + | "body": text(input, "body"), | |
| 884 | + | "decline": input["decline"].as_bool() == Some(true), | |
| 885 | + | }), | |
| 841 | 886 | ) | |
| 842 | 887 | .await | |
| 843 | 888 | } |
| 89 | 89 | | `update_repo` | Change its description or visibility, or protect its default branch. | | |
| 90 | 90 | | `get_repo_settings` | How a repository handles pull requests. | | |
| 91 | 91 | | `update_repo_settings` | Change the approvals a merge needs and how g1t's agents are reviewed and merged. | | |
| 92 | + | | `get_merge_queue` | The pull requests waiting to land, each with the state it is tested in. | | |
| 93 | + | | `message_agent` | Send the agent on a pull request a message; an agent asks another a `question` or hands it work (`handoff`), giving its own pull request as `from_number`. | | |
| 94 | + | | `answer_message` | Answer a question or a handoff another agent sent you, by its id; decline a handoff that is not yours. | | |
| 92 | 95 | | `list_issues` | Issues on a repository, by state and label. | | |
| 93 | 96 | | `get_issue` | An issue with its comments and every pull request made for it. | | |
| 94 | 97 | | `create_issue` | Open an issue, with labels and acceptance checks. | | |
| 125 | 128 | was made. If it has, pull `main` into the fork and push before asking for a | |
| 126 | 129 | merge. | |
| 127 | 130 | ||
| 131 | + | ## Asking each other | |
| 132 | + | ||
| 133 | + | Agents working at the same time can talk through g1t. An agent asks the agent | |
| 134 | + | on another pull request a question, or hands it work that belongs there, | |
| 135 | + | with `message_agent`, naming its own pull request as `from_number`. The | |
| 136 | + | other agent receives it at its next step and replies with | |
| 137 | + | `answer_message`, which reaches the asking agent at its next step in turn. | |
| 138 | + | If the agent asked is not at work, the reply to `message_agent` says so and | |
| 139 | + | points at its change to read instead. Every exchange shows on the outcome | |
| 140 | + | page with where it stands: waiting, read, answered or declined. | |
| 141 | + | ||
| 128 | 142 | ## Reviewing as an agent | |
| 129 | 143 | ||
| 130 | 144 | An agent can review as well as write. Given an issue with several pull |
| 11 | 11 | import type { ReactNode } from "react"; | |
| 12 | 12 | import { Link } from "react-router"; | |
| 13 | 13 | ||
| 14 | − | import type { G1tEvent } from "@g1t/contracts"; | |
| 14 | + | import type { AgentMessage, G1tEvent } from "@g1t/contracts"; | |
| 15 | 15 | ||
| 16 | 16 | import { Avatar, TimeAgo } from "./ui"; | |
| 17 | 17 | ||
| 133 | 133 | </ol> | |
| 134 | 134 | ); | |
| 135 | 135 | } | |
| 136 | + | ||
| 137 | + | /** | |
| 138 | + | * Questions and handoffs between the agents on an outcome's pull requests, | |
| 139 | + | * each with where it stands: asked, read, answered or declined. | |
| 140 | + | */ | |
| 141 | + | export function Exchanges({ exchanges, base }: { exchanges: AgentMessage[]; base: string }) { | |
| 142 | + | if (exchanges.length === 0) return null; | |
| 143 | + | const pull = (number: number) => ( | |
| 144 | + | <Link to={`${base}/pull/${number}`} prefetch="intent" className="font-mono text-fg hover:underline"> | |
| 145 | + | #{number} | |
| 146 | + | </Link> | |
| 147 | + | ); | |
| 148 | + | return ( | |
| 149 | + | <ul className="space-y-3"> | |
| 150 | + | {exchanges.map((exchange) => { | |
| 151 | + | const state = exchange.answer | |
| 152 | + | ? exchange.declined | |
| 153 | + | ? { label: "Declined", tone: "text-warn ring-warn/40" } | |
| 154 | + | : { label: exchange.kind === "handoff" ? "Taken on" : "Answered", tone: "text-accent ring-accent/40" } | |
| 155 | + | : exchange.deliveredAt | |
| 156 | + | ? { label: "Read", tone: "text-info ring-info/40" } | |
| 157 | + | : { label: "Waiting to be read", tone: "text-faint ring-line" }; | |
| 158 | + | return ( | |
| 159 | + | <li key={exchange.id} className="rounded-2xl bg-merged/[0.05] p-4 ring-1 ring-merged/25"> | |
| 160 | + | <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted"> | |
| 161 | + | <Avatar name="g1t-agent" size={14} /> | |
| 162 | + | <span> | |
| 163 | + | The agent on {exchange.fromNumber != null ? pull(exchange.fromNumber) : exchange.author}{" "} | |
| 164 | + | {exchange.kind === "handoff" ? "handed work to" : "asked"} the agent on {pull(exchange.toNumber)} | |
| 165 | + | </span> | |
| 166 | + | <span className={`ml-auto rounded-full px-2 py-0.5 ring-1 ${state.tone}`}>{state.label}</span> | |
| 167 | + | </p> | |
| 168 | + | <p className="mt-2 text-sm">{exchange.body}</p> | |
| 169 | + | {exchange.answer && ( | |
| 170 | + | <p className="mt-2 border-l-2 border-merged/40 pl-3 text-sm text-fg-soft">{exchange.answer}</p> | |
| 171 | + | )} | |
| 172 | + | </li> | |
| 173 | + | ); | |
| 174 | + | })} | |
| 175 | + | </ul> | |
| 176 | + | ); | |
| 177 | + | } |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/plan"; | |
| 8 | 8 | import { Markdown } from "../../components/markdown"; | |
| 9 | − | import { Activity } from "../../components/activity"; | |
| 9 | + | import { Activity, Exchanges } from "../../components/activity"; | |
| 10 | 10 | import { Outcome } from "../../components/outcome"; | |
| 11 | 11 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 12 | 12 | import { Label } from "../../components/work"; | |
| 122 | 122 | {converging && ( | |
| 123 | 123 | <section className="mt-8"> | |
| 124 | 124 | <Outcome plan={plan} base={base} costMicros={costMicros} /> | |
| 125 | + | {plan.exchanges.length > 0 && ( | |
| 126 | + | <div className="mt-10"> | |
| 127 | + | <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">Agents talking</h3> | |
| 128 | + | <div className="mt-3 max-w-3xl"> | |
| 129 | + | <Exchanges exchanges={plan.exchanges} base={base} /> | |
| 130 | + | </div> | |
| 131 | + | </div> | |
| 132 | + | )} | |
| 125 | 133 | {activity.length > 0 && ( | |
| 126 | 134 | <div className="mt-10"> | |
| 127 | 135 | <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3> |
| 464 | 464 | <li key={message.id} className="flex items-start gap-2 text-sm"> | |
| 465 | 465 | <Avatar name={message.author} size={18} /> | |
| 466 | 466 | <span className="min-w-0 grow"> | |
| 467 | − | <span className="font-medium">{message.author}</span>{" "} | |
| 468 | − | <span className="text-muted">to the agent:</span> {message.body} | |
| 467 | + | <span className="font-medium"> | |
| 468 | + | {message.fromNumber != null ? `The agent on #${message.fromNumber}` : message.author} | |
| 469 | + | </span>{" "} | |
| 470 | + | <span className="text-muted"> | |
| 471 | + | {message.kind === "question" | |
| 472 | + | ? "asked:" | |
| 473 | + | : message.kind === "handoff" | |
| 474 | + | ? "handed over:" | |
| 475 | + | : message.kind === "answer" | |
| 476 | + | ? "answered:" | |
| 477 | + | : "to the agent:"} | |
| 478 | + | </span>{" "} | |
| 479 | + | {message.body} | |
| 480 | + | {message.answer && ( | |
| 481 | + | <span className="mt-1 block border-l-2 border-merged/40 pl-2 text-muted"> | |
| 482 | + | {message.declined ? "Declined: " : "Answer: "} | |
| 483 | + | {message.answer} | |
| 484 | + | </span> | |
| 485 | + | )} | |
| 469 | 486 | </span> | |
| 470 | 487 | <span className={`shrink-0 text-xs ${message.deliveredAt ? "text-accent" : "text-faint"}`}> | |
| 471 | 488 | {message.deliveredAt ? "read by the agent" : "waiting for its next step"} |
| 429 | 429 | pub created_at: String, | |
| 430 | 430 | /// RFC 3339. When the agent received it; null until then. | |
| 431 | 431 | pub delivered_at: Option<String>, | |
| 432 | + | /// `message` from a person, or from another pull request's agent a | |
| 433 | + | /// `question`, a `handoff` of work, or the `answer` to one. | |
| 434 | + | #[serde(default = "message_kind")] | |
| 435 | + | pub kind: String, | |
| 436 | + | /// The pull request whose agent sent it, when an agent did. | |
| 437 | + | #[serde(default)] | |
| 438 | + | pub from_number: Option<u32>, | |
| 439 | + | /// The pull request it was sent to. | |
| 440 | + | #[serde(default)] | |
| 441 | + | pub to_number: u32, | |
| 442 | + | /// For a question or handoff: the reply, once there is one. | |
| 443 | + | #[serde(default)] | |
| 444 | + | pub answer: Option<String>, | |
| 445 | + | /// For a handoff: whether it was declined. | |
| 446 | + | #[serde(default)] | |
| 447 | + | pub declined: bool, | |
| 448 | + | /// For the agent that sent it: what to expect, when the agent it asked | |
| 449 | + | /// is not at work and will not answer soon. | |
| 450 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 451 | + | pub hint: Option<String>, | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | fn message_kind() -> String { | |
| 455 | + | "message".to_owned() | |
| 432 | 456 | } | |
| 433 | 457 | ||
| 434 | 458 | /// `message_agent`: sends the agent working on a pull request a message. | |
| 440 | 464 | pub repo: RepoPath, | |
| 441 | 465 | pub number: u32, | |
| 442 | 466 | pub body: String, | |
| 467 | + | /// For an agent: `question` or `handoff`; a person's is a `message`. | |
| 468 | + | #[serde(default)] | |
| 469 | + | pub kind: Option<String>, | |
| 470 | + | /// For an agent: the pull request it is working on, which the reply | |
| 471 | + | /// goes back to. | |
| 472 | + | #[serde(default)] | |
| 473 | + | pub from_number: Option<u32>, | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | /// `answer_message`: replies to a question or a handoff an agent received, | |
| 477 | + | /// accepting or declining a handoff. The reply reaches the asking agent at | |
| 478 | + | /// its next step. Returns `Outcome<AgentMessage>`, the message answered. | |
| 479 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 480 | + | pub struct AnswerMessageArgs { | |
| 481 | + | pub actor: User, | |
| 482 | + | pub repo: RepoPath, | |
| 483 | + | pub id: String, | |
| 484 | + | pub body: String, | |
| 485 | + | #[serde(default)] | |
| 486 | + | pub decline: bool, | |
| 443 | 487 | } | |
| 444 | 488 | ||
| 445 | 489 | /// `take_messages`: the messages not yet delivered to the agent working on | |
| 834 | 878 | /// Filled in by `get_plan` only. | |
| 835 | 879 | #[serde(default)] | |
| 836 | 880 | pub progress: Vec<IssueProgress>, | |
| 881 | + | /// Questions and handoffs between the agents on its pull requests, | |
| 882 | + | /// newest first. Filled in by `get_plan` only. | |
| 883 | + | #[serde(default)] | |
| 884 | + | pub exchanges: Vec<AgentMessage>, | |
| 837 | 885 | } | |
| 838 | 886 | ||
| 839 | 887 | /// Where one issue of an applied plan stands. |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | #[derive(Deserialize)] | |
| 31 | + | #[serde(rename_all = "camelCase")] | |
| 31 | 32 | struct Message { | |
| 33 | + | id: String, | |
| 32 | 34 | author: String, | |
| 33 | 35 | body: String, | |
| 36 | + | #[serde(default)] | |
| 37 | + | kind: String, | |
| 38 | + | #[serde(default)] | |
| 39 | + | from_number: Option<u32>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// One message as the agent should read it, with how to reply where it can. | |
| 43 | + | fn told(message: &Message) -> String { | |
| 44 | + | let from = message | |
| 45 | + | .from_number | |
| 46 | + | .map_or_else(|| message.author.clone(), |number| format!("The agent on #{number}")); | |
| 47 | + | match message.kind.as_str() { | |
| 48 | + | "question" => format!( | |
| 49 | + | "{from} asks you (message {}): {}\nAnswer it with the answer_message tool and that id.", | |
| 50 | + | message.id, message.body | |
| 51 | + | ), | |
| 52 | + | "handoff" => format!( | |
| 53 | + | "{from} hands you work that belongs in your pull request (message {}): {}\nTake it on, or decline it if it is not yours, with the answer_message tool and that id.", | |
| 54 | + | message.id, message.body | |
| 55 | + | ), | |
| 56 | + | "answer" => format!("{from} answered you: {}", message.body), | |
| 57 | + | _ => format!("{} says: {}", message.author, message.body), | |
| 58 | + | } | |
| 34 | 59 | } | |
| 35 | 60 | ||
| 36 | 61 | fn now_ms() -> u128 { | |
| 70 | 95 | let Some(messages) = take(stopping).filter(|messages| !messages.is_empty()) else { | |
| 71 | 96 | return 0; | |
| 72 | 97 | }; | |
| 73 | − | let said: Vec<String> = messages | |
| 74 | − | .iter() | |
| 75 | − | .map(|message| format!("{} says: {}", message.author, message.body)) | |
| 76 | − | .collect(); | |
| 98 | + | let said: Vec<String> = messages.iter().map(told).collect(); | |
| 77 | 99 | let context = format!( | |
| 78 | − | "A person watching your work just sent you a message on the pull request. Take it into account from now on; it outranks your earlier instructions where they conflict.\n\n{}", | |
| 100 | + | "New messages on your pull request. A person's outranks your earlier instructions where they conflict; another agent's is a colleague's.\n\n{}", | |
| 79 | 101 | said.join("\n\n") | |
| 80 | 102 | ); | |
| 81 | 103 | let output = if stopping { |
| 381 | 381 | finishedAt: string | null; | |
| 382 | 382 | /** Once applied: where each issue it opened stands now, in plan order. */ | |
| 383 | 383 | progress: IssueProgress[]; | |
| 384 | + | /** Questions and handoffs between its pull requests' agents, newest first. */ | |
| 385 | + | exchanges: AgentMessage[]; | |
| 384 | 386 | }; | |
| 385 | 387 | ||
| 386 | 388 | /** What a sandbox needs to write a plan. */ | |
| 708 | 710 | createdAt: string; | |
| 709 | 711 | /** When the agent received it; null until then. */ | |
| 710 | 712 | deliveredAt: string | null; | |
| 713 | + | /** `message` from a person; from an agent a `question`, `handoff` or `answer`. */ | |
| 714 | + | kind: "message" | "question" | "handoff" | "answer"; | |
| 715 | + | /** The pull request whose agent sent it, when an agent did. */ | |
| 716 | + | fromNumber: number | null; | |
| 717 | + | /** The pull request it was sent to. */ | |
| 718 | + | toNumber: number; | |
| 719 | + | /** The reply to a question or handoff, once there is one. */ | |
| 720 | + | answer: string | null; | |
| 721 | + | declined: boolean; | |
| 722 | + | /** For the sending agent: what to expect when the one it asked is not at work. */ | |
| 723 | + | hint?: string; | |
| 711 | 724 | }; | |
| 712 | 725 | ||
| 713 | 726 | export type QueueState = "waiting" | "testing" | "passed" | "failed" | "landed" | "removed"; |
| 209 | 209 | "list_events", | |
| 210 | 210 | // Messages people send it while it works, picked up between steps. | |
| 211 | 211 | "take_messages", | |
| 212 | + | // Asking the agents on other pull requests, and answering them. | |
| 213 | + | "message_agent", | |
| 214 | + | "answer_message", | |
| 212 | 215 | ]; | |
| 213 | 216 | ||
| 214 | 217 | /** How an agent is told to use g1t's tools to work with the others. */ | |
| 215 | 218 | const WORKING_WITH_OTHERS = | |
| 216 | − | "You have g1t's own tools (mcp__g1t__…) for this repository. Use them to work with the other agents and people here rather than around them: if you find something that needs doing outside your task, open an issue for it with create_issue, saying what and why and naming the pull request you are working on, instead of widening your change; to tell another pull request's author something, such as a conflict you can see coming, comment on it with add_comment; get_pull_request shows another pull request's change and the files it shares with others. Mention anything you opened or said in your summary."; | |
| 219 | + | "You have g1t's own tools (mcp__g1t__…) for this repository. Use them to work with the other agents and people here rather than around them: if you find something that needs doing outside your task, open an issue for it with create_issue, saying what and why and naming the pull request you are working on, instead of widening your change; to tell another pull request's author something, such as a conflict you can see coming, comment on it with add_comment; to ask the agent working on another pull request something, or hand it work that belongs there, use message_agent with kind question or handoff and your own pull request as from_number, and keep working: the answer reaches you at a later step. Answer what other agents send you with answer_message. get_pull_request shows another pull request's change and the files it shares with others. Mention anything you opened, asked or answered in your summary."; | |
| 217 | 220 | ||
| 218 | 221 | /** Longest that what people said on a pull request is passed on. */ | |
| 219 | 222 | const MAX_PEOPLE_SAID_CHARS = 6000; | |
| 249 | 252 | /** What the author is told when sent back to a pull request it made. */ | |
| 250 | 253 | function buildRevisionPrompt(job: LifecycleJob, inFlight: string | null, peopleSaid: string | null): string { | |
| 251 | 254 | const parts = [ | |
| 252 | − | "You are a coding agent working in the git repository checked out in the current directory. It holds a change you made earlier, which is open as a pull request.", | |
| 255 | + | `You are a coding agent working in the git repository checked out in the current directory. It holds a change you made earlier, which is open as pull request #${job.number}.`, | |
| 253 | 256 | job.issue | |
| 254 | 257 | ? `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}` | |
| 255 | 258 | : `The pull request: ${job.title}`, | |
| 265 | 268 | return parts.filter(Boolean).join("\n\n"); | |
| 266 | 269 | } | |
| 267 | 270 | ||
| 268 | − | function buildPrompt(issue: Issue, instructions: string, inFlight: string | null): string { | |
| 271 | + | function buildPrompt(issue: Issue, instructions: string, inFlight: string | null, pullNumber: number): string { | |
| 269 | 272 | const parts = [ | |
| 270 | − | "You are a coding agent working in the git repository checked out in the current directory.", | |
| 273 | + | `You are a coding agent working in the git repository checked out in the current directory, on pull request #${pullNumber} of this repository.`, | |
| 271 | 274 | `Issue #${issue.number}: ${issue.title}`, | |
| 272 | 275 | issue.body, | |
| 273 | 276 | ]; | |
| 1006 | 1009 | issue, | |
| 1007 | 1010 | input.instructions?.trim() ?? "", | |
| 1008 | 1011 | await this.inFlight(actor, repo, pull.number), | |
| 1012 | + | pull.number, | |
| 1009 | 1013 | ), | |
| 1010 | 1014 | ...model.value, | |
| 1011 | 1015 | }, |
| 1 | + | -- Agents asking each other: a question or a handoff from the agent on one | |
| 2 | + | -- pull request to the agent on another, and the answer, which goes back. | |
| 3 | + | ALTER TABLE agent_messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'; | |
| 4 | + | ALTER TABLE agent_messages ADD COLUMN repo_id TEXT; | |
| 5 | + | ALTER TABLE agent_messages ADD COLUMN from_number INTEGER; | |
| 6 | + | ALTER TABLE agent_messages ADD COLUMN to_number INTEGER; | |
| 7 | + | ALTER TABLE agent_messages ADD COLUMN answer TEXT; | |
| 8 | + | ALTER TABLE agent_messages ADD COLUMN answered_at TEXT; | |
| 9 | + | ALTER TABLE agent_messages ADD COLUMN declined INTEGER NOT NULL DEFAULT 0; | |
| 10 | + | CREATE INDEX agent_messages_by_repo ON agent_messages (repo_id, created_at); |
| 1762 | 1762 | "remove_from_queue" => reply(&work.remove_from_queue(args(body)?).await?), | |
| 1763 | 1763 | "message_agent" => reply(&work.message_agent(args(body)?).await?), | |
| 1764 | 1764 | "locate_pull" => reply(&work.locate_pull(args(body)?).await?), | |
| 1765 | + | "answer_message" => reply(&work.answer_message(args(body)?).await?), | |
| 1765 | 1766 | "take_messages" => reply(&work.take_messages(args(body)?).await?), | |
| 1766 | 1767 | "catch_up_job" => reply(&work.catch_up_job(args(body)?).await?), | |
| 1767 | 1768 | "get_settings" => reply(&work.get_settings(args(body)?).await?), |
| 1 | − | //! Steering: people send the agent at work on a pull request a message, | |
| 2 | − | //! and the agent receives it at its next step. The sandbox asks for | |
| 3 | − | //! undelivered messages after each of the agent's tool calls. | |
| 1 | + | //! Messages to agents at work. People steer the agent on a pull request; | |
| 2 | + | //! agents ask each other questions and hand each other work, and answer. | |
| 3 | + | //! Each reaches its agent at the agent's next step: the sandbox asks for | |
| 4 | + | //! undelivered messages after each tool call and before it stops. | |
| 4 | 5 | ||
| 5 | 6 | use g1t_contracts::time::rfc3339; | |
| 6 | 7 | use g1t_contracts::work::*; | |
| 14 | 15 | /// The longest message an agent is sent. | |
| 15 | 16 | const MAX_MESSAGE_CHARS: usize = 4000; | |
| 16 | 17 | ||
| 18 | + | /// Kinds an agent may send another. | |
| 19 | + | const ASKS: [&str; 2] = ["question", "handoff"]; | |
| 20 | + | ||
| 17 | 21 | #[derive(Deserialize)] | |
| 18 | 22 | struct MessageRow { | |
| 19 | 23 | id: String, | |
| 24 | + | #[allow(dead_code)] | |
| 25 | + | pull_id: String, | |
| 20 | 26 | author_name: String, | |
| 21 | 27 | body: String, | |
| 22 | 28 | created_at: String, | |
| 23 | 29 | delivered_at: Option<String>, | |
| 30 | + | kind: String, | |
| 31 | + | from_number: Option<u32>, | |
| 32 | + | to_number: Option<u32>, | |
| 33 | + | answer: Option<String>, | |
| 34 | + | declined: u32, | |
| 24 | 35 | } | |
| 25 | 36 | ||
| 26 | 37 | impl From<MessageRow> for AgentMessage { | |
| 31 | 42 | body: row.body, | |
| 32 | 43 | created_at: row.created_at, | |
| 33 | 44 | delivered_at: row.delivered_at, | |
| 45 | + | kind: row.kind, | |
| 46 | + | from_number: row.from_number, | |
| 47 | + | to_number: row.to_number.unwrap_or_default(), | |
| 48 | + | answer: row.answer, | |
| 49 | + | declined: row.declined != 0, | |
| 50 | + | hint: None, | |
| 34 | 51 | } | |
| 35 | 52 | } | |
| 36 | 53 | } | |
| 37 | 54 | ||
| 55 | + | /// Who sent a message, in a sentence's words. | |
| 56 | + | fn sender(message: &AgentMessage) -> String { | |
| 57 | + | match message.from_number { | |
| 58 | + | Some(number) => format!("the agent on #{number}"), | |
| 59 | + | None => message.author.clone(), | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 38 | 63 | impl Work { | |
| 39 | 64 | pub(crate) async fn locate_pull(&self, a: LocatePullArgs) -> Result<Outcome<LocatedPull>> { | |
| 40 | 65 | let missing = || Outcome::fail(FailureCode::NotFound, "Pull request not found."); | |
| 84 | 109 | Outcome::Ok(found) => found, | |
| 85 | 110 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 86 | 111 | }; | |
| 112 | + | let from_agent = a.actor.kind == PrincipalKind::Agent; | |
| 113 | + | let kind = match (&a.kind, from_agent) { | |
| 114 | + | (Some(kind), true) if ASKS.contains(&kind.as_str()) => kind.clone(), | |
| 115 | + | (None, true) => "question".to_owned(), | |
| 116 | + | (_, true) => { | |
| 117 | + | return Ok(Outcome::fail( | |
| 118 | + | FailureCode::Invalid, | |
| 119 | + | "An agent sends a question or a handoff.", | |
| 120 | + | )); | |
| 121 | + | } | |
| 122 | + | (_, false) => "message".to_owned(), | |
| 123 | + | }; | |
| 124 | + | if from_agent && a.from_number.is_none() { | |
| 125 | + | return Ok(Outcome::fail( | |
| 126 | + | FailureCode::Invalid, | |
| 127 | + | "Say which pull request you are working on, as from_number; it is where the answer goes.", | |
| 128 | + | )); | |
| 129 | + | } | |
| 87 | 130 | if !a.actor.verified | |
| 88 | 131 | || (pull.author.id != a.actor.id && !a.actor.is_member(&repo.namespace)) | |
| 89 | 132 | { | |
| 103 | 146 | return Ok(Outcome::fail(FailureCode::Invalid, "Write a message.")); | |
| 104 | 147 | } | |
| 105 | 148 | let body: String = body.chars().take(MAX_MESSAGE_CHARS).collect(); | |
| 149 | + | // An agent may name its issue rather than its pull request: the | |
| 150 | + | // answer goes to the issue's pull request that is still open. | |
| 151 | + | let from_number = match (from_agent, a.from_number) { | |
| 152 | + | (true, Some(from)) => match self.pull(&repo.id, from).await? { | |
| 153 | + | Some(_) => Some(from), | |
| 154 | + | None => self.db | |
| 155 | + | .prepare( | |
| 156 | + | "SELECT number AS value FROM pulls | |
| 157 | + | WHERE repo_id = ? AND issue_number = ? AND status IN ('draft', 'open') | |
| 158 | + | ORDER BY number DESC LIMIT 1", | |
| 159 | + | ) | |
| 160 | + | .bind(&[repo.id.as_str().into(), from.into()])? | |
| 161 | + | .first::<u32>(Some("value")) | |
| 162 | + | .await? | |
| 163 | + | .or(Some(from)), | |
| 164 | + | }, | |
| 165 | + | _ => None, | |
| 166 | + | }; | |
| 167 | + | if from_agent && from_number == Some(pull.number) { | |
| 168 | + | return Ok(Outcome::fail(FailureCode::Invalid, "That is your own pull request.")); | |
| 169 | + | } | |
| 170 | + | // Whether the agent asked is at work now, to read it soon. | |
| 171 | + | let at_work = pull.status == PullStatus::Draft | |
| 172 | + | || self | |
| 173 | + | .db | |
| 174 | + | .prepare( | |
| 175 | + | "SELECT 1 AS value FROM pulls | |
| 176 | + | WHERE id = ? AND working_on = 'revision' AND working_until > ?", | |
| 177 | + | ) | |
| 178 | + | .bind(&[pull.id.as_str().into(), rfc3339(now_ms()).into()])? | |
| 179 | + | .first::<u32>(Some("value")) | |
| 180 | + | .await? | |
| 181 | + | .is_some(); | |
| 106 | 182 | let now = now_ms(); | |
| 107 | 183 | let message = AgentMessage { | |
| 108 | 184 | id: new_id("msg", now), | |
| 110 | 186 | body, | |
| 111 | 187 | created_at: rfc3339(now), | |
| 112 | 188 | delivered_at: None, | |
| 189 | + | kind, | |
| 190 | + | from_number, | |
| 191 | + | to_number: pull.number, | |
| 192 | + | answer: None, | |
| 193 | + | declined: false, | |
| 194 | + | hint: None, | |
| 195 | + | }; | |
| 196 | + | self.insert_message(&repo.id, &pull.id, &a.actor.id, &message).await?; | |
| 197 | + | let mut message = message; | |
| 198 | + | if from_agent && !at_work { | |
| 199 | + | message.hint = Some(format!( | |
| 200 | + | "The agent on #{} is not at work right now, so it will not answer soon. Its change is there to read: use get_pull_request and get_pull_request_changes on #{}, and decide from that.", | |
| 201 | + | pull.number, pull.number | |
| 202 | + | )); | |
| 203 | + | } | |
| 204 | + | let said = match (message.kind.as_str(), message.from_number) { | |
| 205 | + | ("question", Some(from)) => format!("was asked a question by the agent on #{from}"), | |
| 206 | + | ("handoff", Some(from)) => format!("was handed work by the agent on #{from}"), | |
| 207 | + | _ => "sent the agent a message".to_owned(), | |
| 113 | 208 | }; | |
| 209 | + | self.note(&repo.id, pull.number, (a.actor.id.as_str(), a.actor.username.as_str()), &said) | |
| 210 | + | .await?; | |
| 211 | + | Ok(Outcome::Ok(message)) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | async fn insert_message( | |
| 215 | + | &self, | |
| 216 | + | repo_id: &str, | |
| 217 | + | pull_id: &str, | |
| 218 | + | author_id: &str, | |
| 219 | + | message: &AgentMessage, | |
| 220 | + | ) -> Result<()> { | |
| 114 | 221 | self.db | |
| 115 | 222 | .prepare( | |
| 116 | − | "INSERT INTO agent_messages (id, pull_id, author_id, author_name, body, created_at) | |
| 117 | − | VALUES (?, ?, ?, ?, ?, ?)", | |
| 223 | + | "INSERT INTO agent_messages | |
| 224 | + | (id, pull_id, repo_id, author_id, author_name, body, created_at, kind, | |
| 225 | + | from_number, to_number) | |
| 226 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 118 | 227 | ) | |
| 119 | 228 | .bind(&[ | |
| 120 | 229 | message.id.as_str().into(), | |
| 121 | − | pull.id.as_str().into(), | |
| 122 | − | a.actor.id.as_str().into(), | |
| 230 | + | pull_id.into(), | |
| 231 | + | repo_id.into(), | |
| 232 | + | author_id.into(), | |
| 123 | 233 | message.author.as_str().into(), | |
| 124 | 234 | message.body.as_str().into(), | |
| 125 | 235 | message.created_at.as_str().into(), | |
| 236 | + | message.kind.as_str().into(), | |
| 237 | + | message | |
| 238 | + | .from_number | |
| 239 | + | .map_or(worker::wasm_bindgen::JsValue::NULL, |n| n.into()), | |
| 240 | + | message.to_number.into(), | |
| 126 | 241 | ])? | |
| 127 | 242 | .run() | |
| 128 | 243 | .await?; | |
| 129 | − | self.note( | |
| 130 | − | &repo.id, | |
| 131 | − | pull.number, | |
| 132 | − | (a.actor.id.as_str(), a.actor.username.as_str()), | |
| 133 | − | "sent the agent a message", | |
| 134 | − | ) | |
| 135 | − | .await?; | |
| 136 | − | Ok(Outcome::Ok(message)) | |
| 244 | + | Ok(()) | |
| 137 | 245 | } | |
| 138 | 246 | ||
| 247 | + | /// The agent asked answers: the reply is recorded on the question and | |
| 248 | + | /// sent back to the asking agent as a message of its own. | |
| 249 | + | pub(crate) async fn answer_message(&self, a: AnswerMessageArgs) -> Result<Outcome<AgentMessage>> { | |
| 250 | + | let viewer = Some(a.actor.clone()); | |
| 251 | + | let repo = match self.repo(&a.repo, &viewer).await? { | |
| 252 | + | Outcome::Ok(repo) => repo, | |
| 253 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 254 | + | }; | |
| 255 | + | if !a.actor.is_member(&repo.namespace) { | |
| 256 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Only members and g1t's agents answer.")); | |
| 257 | + | } | |
| 258 | + | let row = self | |
| 259 | + | .db | |
| 260 | + | .prepare("SELECT * FROM agent_messages WHERE id = ? AND repo_id = ?") | |
| 261 | + | .bind(&[a.id.as_str().into(), repo.id.as_str().into()])? | |
| 262 | + | .first::<MessageRow>(None) | |
| 263 | + | .await?; | |
| 264 | + | let Some(row) = row else { | |
| 265 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such message.")); | |
| 266 | + | }; | |
| 267 | + | let mut asked = AgentMessage::from(row); | |
| 268 | + | if !ASKS.contains(&asked.kind.as_str()) { | |
| 269 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Only a question or a handoff is answered.")); | |
| 270 | + | } | |
| 271 | + | if asked.answer.is_some() { | |
| 272 | + | return Ok(Outcome::fail(FailureCode::Conflict, "It has been answered already.")); | |
| 273 | + | } | |
| 274 | + | let body: String = a.body.trim().chars().take(MAX_MESSAGE_CHARS).collect(); | |
| 275 | + | if body.is_empty() { | |
| 276 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Write an answer.")); | |
| 277 | + | } | |
| 278 | + | let now = now_ms(); | |
| 279 | + | self.db | |
| 280 | + | .prepare("UPDATE agent_messages SET answer = ?, answered_at = ?, declined = ? WHERE id = ?") | |
| 281 | + | .bind(&[ | |
| 282 | + | body.as_str().into(), | |
| 283 | + | rfc3339(now).into(), | |
| 284 | + | u32::from(a.decline).into(), | |
| 285 | + | asked.id.as_str().into(), | |
| 286 | + | ])? | |
| 287 | + | .run() | |
| 288 | + | .await?; | |
| 289 | + | asked.answer = Some(body.clone()); | |
| 290 | + | asked.declined = a.decline; | |
| 291 | + | // Back to whoever asked: the agent on the other pull request. | |
| 292 | + | if let Some(from) = asked.from_number { | |
| 293 | + | if let Some(back) = self.pull(&repo.id, from).await?.filter(|pull| pull.status.is_active()) { | |
| 294 | + | let reply = AgentMessage { | |
| 295 | + | id: new_id("msg", now), | |
| 296 | + | author: a.actor.username.clone(), | |
| 297 | + | body: if a.decline { format!("Declined: {body}") } else { body }, | |
| 298 | + | created_at: rfc3339(now), | |
| 299 | + | delivered_at: None, | |
| 300 | + | kind: "answer".to_owned(), | |
| 301 | + | from_number: Some(asked.to_number), | |
| 302 | + | to_number: from, | |
| 303 | + | answer: None, | |
| 304 | + | declined: a.decline, | |
| 305 | + | hint: None, | |
| 306 | + | }; | |
| 307 | + | self.insert_message(&repo.id, &back.id, &a.actor.id, &reply).await?; | |
| 308 | + | } | |
| 309 | + | let said = if asked.kind == "handoff" { | |
| 310 | + | if a.decline { "declined the handoff from" } else { "took on the handoff from" } | |
| 311 | + | } else { | |
| 312 | + | "answered the question from" | |
| 313 | + | }; | |
| 314 | + | self.note( | |
| 315 | + | &repo.id, | |
| 316 | + | asked.to_number, | |
| 317 | + | (a.actor.id.as_str(), a.actor.username.as_str()), | |
| 318 | + | &format!("{said} the agent on #{from}"), | |
| 319 | + | ) | |
| 320 | + | .await?; | |
| 321 | + | } | |
| 322 | + | Ok(Outcome::Ok(asked)) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// Questions, handoffs and answers between the agents on these pull | |
| 326 | + | /// requests, newest first. | |
| 327 | + | pub(crate) async fn exchanges(&self, repo_id: &str, numbers: &[u32]) -> Result<Vec<AgentMessage>> { | |
| 328 | + | if numbers.is_empty() { | |
| 329 | + | return Ok(Vec::new()); | |
| 330 | + | } | |
| 331 | + | let rows = self | |
| 332 | + | .db | |
| 333 | + | .prepare( | |
| 334 | + | "SELECT * FROM agent_messages | |
| 335 | + | WHERE repo_id = ? AND kind IN ('question', 'handoff') | |
| 336 | + | ORDER BY created_at DESC LIMIT 50", | |
| 337 | + | ) | |
| 338 | + | .bind(&[repo_id.into()])? | |
| 339 | + | .all() | |
| 340 | + | .await? | |
| 341 | + | .results::<MessageRow>()?; | |
| 342 | + | Ok(rows | |
| 343 | + | .into_iter() | |
| 344 | + | .map(AgentMessage::from) | |
| 345 | + | .filter(|message| { | |
| 346 | + | numbers.contains(&message.to_number) | |
| 347 | + | || message.from_number.is_some_and(|from| numbers.contains(&from)) | |
| 348 | + | }) | |
| 349 | + | .collect()) | |
| 350 | + | } | |
| 351 | + | ||
| 139 | 352 | /// The undelivered messages, marked delivered and recorded in the | |
| 140 | 353 | /// session, for the agent's sandbox. | |
| 141 | 354 | pub(crate) async fn take_messages(&self, a: TakeMessagesArgs) -> Result<Outcome<Vec<AgentMessage>>> { | |
| 170 | 383 | .iter() | |
| 171 | 384 | .map(|message| NewSessionEntry { | |
| 172 | 385 | kind: SessionEntryKind::Prompt, | |
| 173 | − | text: format!("Message from {}: {}", message.author, message.body), | |
| 386 | + | text: match message.kind.as_str() { | |
| 387 | + | "question" => format!("Question from {} ({}): {}", sender(message), message.id, message.body), | |
| 388 | + | "handoff" => format!("Work handed over by {} ({}): {}", sender(message), message.id, message.body), | |
| 389 | + | "answer" => format!("Answer from {}: {}", sender(message), message.body), | |
| 390 | + | _ => format!("Message from {}: {}", message.author, message.body), | |
| 391 | + | }, | |
| 174 | 392 | tool: None, | |
| 175 | 393 | commit: None, | |
| 176 | 394 | }) |
| 74 | 74 | created_at: row.created_at, | |
| 75 | 75 | finished_at: row.finished_at, | |
| 76 | 76 | progress: Vec::new(), | |
| 77 | + | exchanges: Vec::new(), | |
| 77 | 78 | } | |
| 78 | 79 | } | |
| 79 | 80 | } | |
| 262 | 263 | let mut plan: Plan = row.into(); | |
| 263 | 264 | if plan.status == PlanStatus::Applied { | |
| 264 | 265 | plan.progress = self.progress(&repo.id, &plan).await?; | |
| 266 | + | let numbers: Vec<u32> = plan | |
| 267 | + | .progress | |
| 268 | + | .iter() | |
| 269 | + | .flat_map(|item| std::iter::once(item.number).chain(item.pull)) | |
| 270 | + | .collect(); | |
| 271 | + | plan.exchanges = self.exchanges(&repo.id, &numbers).await?; | |
| 265 | 272 | } | |
| 266 | 273 | Outcome::Ok(plan) | |
| 267 | 274 | } |