Commit

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.

syntaqxcommitted Parenta040d37Browse files
13 files+481−320/13 viewed
+48−3
5353 UpdateRepoSettings,
5454 GetMergeQueue,
5555 MessageAgent,
56+ AnswerMessage,
5657 TakeMessages,
5758 ListIssues,
5859 GetIssue,
187188 }
188189
189190 impl Op {
190− pub const ALL: [Op; 34] = [
191+ pub const ALL: [Op; 35] = [
191192 Op::Whoami,
192193 Op::CreateWorkspace,
193194 Op::ListRepos,
198199 Op::UpdateRepoSettings,
199200 Op::GetMergeQueue,
200201 Op::MessageAgent,
202+ Op::AnswerMessage,
201203 Op::TakeMessages,
202204 Op::ListIssues,
203205 Op::GetIssue,
240242 Op::GetRepoSettings => "get_repo_settings",
241243 Op::GetMergeQueue => "get_merge_queue",
242244 Op::MessageAgent => "message_agent",
245+ Op::AnswerMessage => "answer_message",
243246 Op::TakeMessages => "take_messages",
244247 Op::UpdateRepoSettings => "update_repo_settings",
245248 Op::ListIssues => "list_issues",
288291 "Change how a repository handles pull requests. Only the fields given are changed. Members of its workspace only."
289292 }
290293 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."
292298 }
293299 Op::TakeMessages => {
294300 "For a g1t agent at work: the messages people have sent it that it has not seen yet. Each is returned once."
402408 Op::MessageAgent => object(
403409 numbered(json!({
404410 "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+ },
405420 })),
406421 &["repo", "number", "body"],
407422 ),
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+ ),
408432 Op::TakeMessages => object(numbered(json!({})), &["repo", "number"]),
409433 Op::UpdateRepoSettings => object(
410434 json!({
837861 pass(
838862 work,
839863 "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+ }),
841886 )
842887 .await
843888 }
+14−0
8989 | `update_repo` | Change its description or visibility, or protect its default branch. |
9090 | `get_repo_settings` | How a repository handles pull requests. |
9191 | `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. |
9295 | `list_issues` | Issues on a repository, by state and label. |
9396 | `get_issue` | An issue with its comments and every pull request made for it. |
9497 | `create_issue` | Open an issue, with labels and acceptance checks. |
125128 was made. If it has, pull `main` into the fork and push before asking for a
126129 merge.
127130
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+
128142 ## Reviewing as an agent
129143
130144 An agent can review as well as write. Given an issue with several pull
+43−1
1111 import type { ReactNode } from "react";
1212 import { Link } from "react-router";
1313
14−import type { G1tEvent } from "@g1t/contracts";
14+import type { AgentMessage, G1tEvent } from "@g1t/contracts";
1515
1616 import { Avatar, TimeAgo } from "./ui";
1717
133133 </ol>
134134 );
135135 }
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+}
+9−1
66
77 import type { Route } from "./+types/plan";
88 import { Markdown } from "../../components/markdown";
9−import { Activity } from "../../components/activity";
9+import { Activity, Exchanges } from "../../components/activity";
1010 import { Outcome } from "../../components/outcome";
1111 import { Button, ErrorText, TimeAgo } from "../../components/ui";
1212 import { Label } from "../../components/work";
122122 {converging && (
123123 <section className="mt-8">
124124 <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+ )}
125133 {activity.length > 0 && (
126134 <div className="mt-10">
127135 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
+19−2
464464 <li key={message.id} className="flex items-start gap-2 text-sm">
465465 <Avatar name={message.author} size={18} />
466466 <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+ )}
469486 </span>
470487 <span className={`shrink-0 text-xs ${message.deliveredAt ? "text-accent" : "text-faint"}`}>
471488 {message.deliveredAt ? "read by the agent" : "waiting for its next step"}
+48−0
429429 pub created_at: String,
430430 /// RFC 3339. When the agent received it; null until then.
431431 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()
432456 }
433457
434458 /// `message_agent`: sends the agent working on a pull request a message.
440464 pub repo: RepoPath,
441465 pub number: u32,
442466 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,
443487 }
444488
445489 /// `take_messages`: the messages not yet delivered to the agent working on
834878 /// Filled in by `get_plan` only.
835879 #[serde(default)]
836880 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>,
837885 }
838886
839887 /// Where one issue of an applied plan stands.
+27−5
2828 }
2929
3030 #[derive(Deserialize)]
31+#[serde(rename_all = "camelCase")]
3132 struct Message {
33+ id: String,
3234 author: String,
3335 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+ }
3459 }
3560
3661 fn now_ms() -> u128 {
7095 let Some(messages) = take(stopping).filter(|messages| !messages.is_empty()) else {
7196 return 0;
7297 };
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();
7799 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{}",
79101 said.join("\n\n")
80102 );
81103 let output = if stopping {
+13−0
381381 finishedAt: string | null;
382382 /** Once applied: where each issue it opened stands now, in plan order. */
383383 progress: IssueProgress[];
384+ /** Questions and handoffs between its pull requests' agents, newest first. */
385+ exchanges: AgentMessage[];
384386 };
385387
386388 /** What a sandbox needs to write a plan. */
708710 createdAt: string;
709711 /** When the agent received it; null until then. */
710712 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;
711724 };
712725
713726 export type QueueState = "waiting" | "testing" | "passed" | "failed" | "landed" | "removed";
+8−4
209209 "list_events",
210210 // Messages people send it while it works, picked up between steps.
211211 "take_messages",
212+ // Asking the agents on other pull requests, and answering them.
213+ "message_agent",
214+ "answer_message",
212215 ];
213216
214217 /** How an agent is told to use g1t's tools to work with the others. */
215218 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.";
217220
218221 /** Longest that what people said on a pull request is passed on. */
219222 const MAX_PEOPLE_SAID_CHARS = 6000;
249252 /** What the author is told when sent back to a pull request it made. */
250253 function buildRevisionPrompt(job: LifecycleJob, inFlight: string | null, peopleSaid: string | null): string {
251254 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}.`,
253256 job.issue
254257 ? `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`
255258 : `The pull request: ${job.title}`,
265268 return parts.filter(Boolean).join("\n\n");
266269 }
267270
268−function buildPrompt(issue: Issue, instructions: string, inFlight: string | null): string {
271+function buildPrompt(issue: Issue, instructions: string, inFlight: string | null, pullNumber: number): string {
269272 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.`,
271274 `Issue #${issue.number}: ${issue.title}`,
272275 issue.body,
273276 ];
10061009 issue,
10071010 input.instructions?.trim() ?? "",
10081011 await this.inFlight(actor, repo, pull.number),
1012+ pull.number,
10091013 ),
10101014 ...model.value,
10111015 },
+10−0
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);
+1−0
17621762 "remove_from_queue" => reply(&work.remove_from_queue(args(body)?).await?),
17631763 "message_agent" => reply(&work.message_agent(args(body)?).await?),
17641764 "locate_pull" => reply(&work.locate_pull(args(body)?).await?),
1765+ "answer_message" => reply(&work.answer_message(args(body)?).await?),
17651766 "take_messages" => reply(&work.take_messages(args(body)?).await?),
17661767 "catch_up_job" => reply(&work.catch_up_job(args(body)?).await?),
17671768 "get_settings" => reply(&work.get_settings(args(body)?).await?),
+234−16
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.
45
56 use g1t_contracts::time::rfc3339;
67 use g1t_contracts::work::*;
1415 /// The longest message an agent is sent.
1516 const MAX_MESSAGE_CHARS: usize = 4000;
1617
18+/// Kinds an agent may send another.
19+const ASKS: [&str; 2] = ["question", "handoff"];
20+
1721 #[derive(Deserialize)]
1822 struct MessageRow {
1923 id: String,
24+ #[allow(dead_code)]
25+ pull_id: String,
2026 author_name: String,
2127 body: String,
2228 created_at: String,
2329 delivered_at: Option<String>,
30+ kind: String,
31+ from_number: Option<u32>,
32+ to_number: Option<u32>,
33+ answer: Option<String>,
34+ declined: u32,
2435 }
2536
2637 impl From<MessageRow> for AgentMessage {
3142 body: row.body,
3243 created_at: row.created_at,
3344 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,
3451 }
3552 }
3653 }
3754
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+
3863 impl Work {
3964 pub(crate) async fn locate_pull(&self, a: LocatePullArgs) -> Result<Outcome<LocatedPull>> {
4065 let missing = || Outcome::fail(FailureCode::NotFound, "Pull request not found.");
84109 Outcome::Ok(found) => found,
85110 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
86111 };
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+ }
87130 if !a.actor.verified
88131 || (pull.author.id != a.actor.id && !a.actor.is_member(&repo.namespace))
89132 {
103146 return Ok(Outcome::fail(FailureCode::Invalid, "Write a message."));
104147 }
105148 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();
106182 let now = now_ms();
107183 let message = AgentMessage {
108184 id: new_id("msg", now),
110186 body,
111187 created_at: rfc3339(now),
112188 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(),
113208 };
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<()> {
114221 self.db
115222 .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
118227 )
119228 .bind(&[
120229 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(),
123233 message.author.as_str().into(),
124234 message.body.as_str().into(),
125235 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(),
126241 ])?
127242 .run()
128243 .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(())
137245 }
138246
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+
139352 /// The undelivered messages, marked delivered and recorded in the
140353 /// session, for the agent's sandbox.
141354 pub(crate) async fn take_messages(&self, a: TakeMessagesArgs) -> Result<Outcome<Vec<AgentMessage>>> {
170383 .iter()
171384 .map(|message| NewSessionEntry {
172385 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+ },
174392 tool: None,
175393 commit: None,
176394 })
+7−0
7474 created_at: row.created_at,
7575 finished_at: row.finished_at,
7676 progress: Vec::new(),
77+ exchanges: Vec::new(),
7778 }
7879 }
7980 }
262263 let mut plan: Plan = row.into();
263264 if plan.status == PlanStatus::Applied {
264265 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?;
265272 }
266273 Outcome::Ok(plan)
267274 }