Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
889 linesCodeBlameRaw
1//! The tools Ask g1t answers with, and what it may do with them.
2//!
3//! **g1t's own tools.** Ask is an MCP client of g1t's own MCP server: it
4//! lists the tools there and calls them, so the model gets exactly the tool
5//! definitions any agent gets, and every call goes through the API's own
6//! permission checks, audit log and rate limits. It calls them with a
7//! credential identity makes for the one answer (`create_ask_token`): the
8//! person's own access, read scopes only, for the one workspace, minutes
9//! long, revoked when the answer ends. The model never sees it, nor does
10//! the browser. The server lists only the actions the credential may use,
11//! and refuses any other.
12//!
13//! **What Ask may do** is decided here, per call, before anything runs
14//! ([`Toolbox::decide`]): a read runs; anything else is refused. Ask never
15//! does the work itself: when someone asks for work, it hands it to a
16//! session in their own harness. Starting that session is a [`Kind::Dispatch`]
17//! (and so is opening the issue the work hangs off): it will run only after
18//! the person confirms it, which is the next step and not built yet.
19//!
20//! **g1t-native tools**, that are not operations of the MCP server, are
21//! registered in [`native`]: `start_session` goes there when sessions can
22//! be started on the workspace's own Anthropic key.
23//!
24//! The rest is plain functions, tested on their own: a step's label, link
25//! and summary for the person watching, a result trimmed to fit, and the
26//! pages an answer drew on.
27
28use std::collections::{BTreeMap, HashSet};
29
30use g1t_contracts::ai::{ASK_MAX_SOURCES, AskEvent, AskSource};
31use serde_json::{Map, Value, json};
32use worker::Method;
33
34use crate::ask::ToolSpec;
35use crate::http;
36
37/// The MCP protocol version Ask speaks.
38const PROTOCOL: &str = "2025-06-18";
39
40/// g1t's MCP server, as the person asking.
41#[derive(Clone)]
42pub struct Mcp {
43 pub url: String,
44 pub token: String,
45}
46
47/// A tool as the MCP server lists it.
48#[derive(Clone, Debug, PartialEq)]
49pub struct Listed {
50 pub spec: ToolSpec,
51 /// Every action the caller may use only reads.
52 pub read_only: bool,
53 /// The actions offered, from the schema's `action` enum.
54 pub actions: Vec<String>,
55 /// The action a call without one runs.
56 pub default_action: Option<String>,
57}
58
59impl Mcp {
60 async fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
61 let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
62 let authorization = format!("Bearer {}", self.token);
63 let headers = [
64 ("authorization", authorization.as_str()),
65 ("content-type", "application/json"),
66 ("accept", "application/json, text/event-stream"),
67 ("mcp-protocol-version", PROTOCOL),
68 ];
69 let answer = http::send(Method::Post, &self.url, &headers, Some(body))
70 .await
71 .map_err(|error| format!("g1t could not be reached: {error}"))?;
72 if !answer.ok() {
73 return Err(match answer.status {
74 401 => "g1t did not accept Ask's credential.".to_owned(),
75 429 => "g1t is busy: too many requests. Try again in a minute.".to_owned(),
76 status => format!("g1t answered {status}."),
77 });
78 }
79 let said = answer.json();
80 if let Some(message) = said["error"]["message"].as_str() {
81 return Err(message.to_owned());
82 }
83 Ok(said["result"].clone())
84 }
85
86 /// The tools the person may use, as the server lists them for them.
87 pub async fn list(&self) -> Result<Vec<Listed>, String> {
88 let result = self.rpc("tools/list", json!({})).await?;
89 Ok(listed(&result))
90 }
91
92 /// Runs a tool: its result as text, and whether it is a refusal or an
93 /// error.
94 pub async fn call(&self, name: &str, arguments: &Value) -> Result<(String, bool), String> {
95 let result = self.rpc("tools/call", json!({ "name": name, "arguments": arguments })).await?;
96 let text: Vec<&str> = result["content"]
97 .as_array()
98 .into_iter()
99 .flatten()
100 .filter(|part| part["type"] == "text")
101 .filter_map(|part| part["text"].as_str())
102 .collect();
103 Ok((text.join("\n"), result["isError"].as_bool() == Some(true)))
104 }
105}
106
107/// `tools/list`'s result read.
108pub fn listed(result: &Value) -> Vec<Listed> {
109 result["tools"]
110 .as_array()
111 .into_iter()
112 .flatten()
113 .filter_map(|tool| {
114 let name = tool["name"].as_str().filter(|name| !name.is_empty())?;
115 let schema = tool["inputSchema"].clone();
116 let action = &schema["properties"]["action"];
117 Some(Listed {
118 spec: ToolSpec {
119 name: name.to_owned(),
120 description: tool["description"].as_str().unwrap_or_default().to_owned(),
121 input_schema: if schema.is_object() { schema.clone() } else { json!({ "type": "object" }) },
122 },
123 read_only: tool["annotations"]["readOnlyHint"].as_bool() == Some(true),
124 actions: action["enum"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(),
125 default_action: action["default"].as_str().map(str::to_owned),
126 })
127 })
128 .collect()
129}
130
131/// What a tool does, for deciding whether Ask may run it.
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub enum Kind {
134 /// Reads: runs.
135 Read,
136 /// Hands work off: starts a session in the person's own harness, or
137 /// opens the issue the work hangs off. Runs only once the person
138 /// confirms it: the answer shows it as a proposal to start.
139 Dispatch,
140 /// Anything else Ask does not do.
141 Change,
142}
143
144/// Whether a call runs.
145#[derive(Debug, PartialEq)]
146pub enum Decision {
147 Run,
148 /// Needs the person's yes first.
149 Confirm,
150 Refuse(String),
151}
152
153/// Where a tool comes from.
154#[derive(Clone, Debug, PartialEq)]
155enum Origin {
156 Mcp { actions: Vec<String>, default_action: Option<String> },
157 Native,
158}
159
160#[derive(Clone, Debug)]
161struct Entry {
162 origin: Origin,
163 kind: Kind,
164}
165
166/// The tools one answer may use.
167#[derive(Clone, Debug, Default)]
168pub struct Toolbox {
169 specs: Vec<ToolSpec>,
170 entries: BTreeMap<String, Entry>,
171}
172
173/// A g1t-native tool: one that is not an operation of the MCP server.
174pub struct Native {
175 pub spec: ToolSpec,
176 pub kind: Kind,
177}
178
179/// The g1t-native tool that hands work off: Ask proposes a session and the
180/// person starts it from the card the answer shows. The click is the
181/// confirmation, so the model never starts anything itself.
182pub const PROPOSE_SESSION: &str = "propose_session";
183
184/// The g1t-native tools Ask offers: `propose_session`, a
185/// [`Kind::Dispatch`] that the loop turns into a proposal event rather
186/// than running.
187pub fn native() -> Vec<Native> {
188 vec![Native {
189 spec: ToolSpec {
190 name: PROPOSE_SESSION.to_owned(),
191 description: "Propose handing work to a coding session in the person's own harness (their Claude, with their skills and subagents), tied to an issue. Use it when they ask for work to be done: planning, implementing, fixing or reviewing. It starts nothing: the person sees a card to start the session on their workspace's Anthropic account, or to copy a Claude Code command to run it themselves. Give `issue` when the work has one (read it first), else `title` and `body` for a new issue. Propose one session per piece of work. To build something new from nothing (an app, a site, a service), set `new_repository` with a `repo` in the workspace that does not exist yet: the card creates it, turns on its deployments (unless `deploy` is false), opens the issue and starts the session, which pushes the first version to the default branch, and that deploys it."
192 .to_owned(),
193 input_schema: json!({
194 "type": "object",
195 "properties": {
196 "repo": { "type": "string", "description": "The repository, owner/name. With new_repository, a new name in the workspace: short, lowercase, words joined by -." },
197 "new_repository": { "type": "boolean", "description": "The repository does not exist yet: the card creates it first. Needs title, body and mode implement." },
198 "private": { "type": "boolean", "description": "With new_repository: make it private. Public unless they ask." },
199 "deploy": { "type": "boolean", "description": "With new_repository: turn on its deployments, so the first push goes live on g1t.page. Default true." },
200 "issue": { "type": "integer", "description": "The issue the work belongs to." },
201 "title": { "type": "string", "description": "A new issue's title, when there is no issue yet." },
202 "body": { "type": "string", "description": "A new issue's description: what to do and what done means." },
203 "mode": { "type": "string", "enum": ["plan", "implement", "review"], "description": "plan: work out how and post the plan, no code. implement: make the change and open a pull request. review: review the issue's pull request." },
204 "instruction": { "type": "string", "description": "Anything the session should know beyond the issue." }
205 },
206 "required": ["repo", "mode"]
207 }),
208 },
209 kind: Kind::Dispatch,
210 }]
211}
212
213/// A proposal from a `propose_session` call, or what is wrong with it.
214pub fn proposal(id: &str, arguments: &Value) -> Result<AskEvent, String> {
215 let text = |key: &str| arguments[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned);
216 let repo = text("repo").filter(|repo| repo.split('/').count() == 2).ok_or("Give the repository as owner/name.")?;
217 let issue = arguments["issue"].as_u64().filter(|number| *number > 0);
218 let title = text("title");
219 if issue.is_none() && title.is_none() {
220 return Err("Give the issue the work belongs to, or a title and body for a new one.".to_owned());
221 }
222 let mode = match text("mode").as_deref() {
223 Some(mode @ ("plan" | "implement" | "review")) => mode.to_owned(),
224 _ => return Err("Give a mode: plan, implement or review.".to_owned()),
225 };
226 let new_repository = arguments["new_repository"].as_bool() == Some(true);
227 if new_repository {
228 let name = repo.split('/').nth(1).unwrap_or_default();
229 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) {
230 return Err("A new repository's name is letters, digits, - _ and . only, such as hello-world.".to_owned());
231 }
232 if issue.is_some() || mode != "implement" {
233 return Err("A new repository has no issues yet: give a title and body for its first, and mode implement.".to_owned());
234 }
235 }
236 let clip = |value: Option<String>, max: usize| value.map(|value| value.chars().take(max).collect::<String>());
237 Ok(AskEvent::Proposal {
238 id: id.to_owned(),
239 repo,
240 issue,
241 title: if issue.is_some() { None } else { clip(title, 200) },
242 body: if issue.is_some() { None } else { clip(text("body"), 8_000) },
243 mode,
244 instruction: clip(text("instruction"), 4_000),
245 new_repository,
246 private: new_repository && arguments["private"].as_bool() == Some(true),
247 deploy: new_repository && arguments["deploy"].as_bool() != Some(false),
248 })
249}
250
251/// Runs a g1t-native tool. `propose_session` never runs here: the loop
252/// turns it into a proposal.
253pub async fn run_native(name: &str, _arguments: &Value) -> Result<(String, bool), String> {
254 Err(format!("{name} is not something Ask can do."))
255}
256
257impl Toolbox {
258 /// From what the MCP server listed, and the native tools.
259 pub fn new(listed: Vec<Listed>, natives: Vec<Native>) -> Self {
260 let mut toolbox = Toolbox::default();
261 for tool in listed {
262 // A read-only credential lists reads only; a tool that says
263 // otherwise is not one Ask uses.
264 let kind = if tool.read_only { Kind::Read } else { Kind::Change };
265 if kind != Kind::Read {
266 continue;
267 }
268 toolbox.entries.insert(
269 tool.spec.name.clone(),
270 Entry { origin: Origin::Mcp { actions: tool.actions, default_action: tool.default_action }, kind },
271 );
272 toolbox.specs.push(tool.spec);
273 }
274 for tool in natives {
275 if toolbox.entries.contains_key(&tool.spec.name) {
276 continue;
277 }
278 toolbox.entries.insert(tool.spec.name.clone(), Entry { origin: Origin::Native, kind: tool.kind });
279 toolbox.specs.push(tool.spec);
280 }
281 toolbox
282 }
283
284 pub fn specs(&self) -> &[ToolSpec] {
285 &self.specs
286 }
287
288 pub fn is_empty(&self) -> bool {
289 self.specs.is_empty()
290 }
291
292 pub fn is_native(&self, name: &str) -> bool {
293 self.entries.get(name).is_some_and(|entry| entry.origin == Origin::Native)
294 }
295
296 /// Whether a call the model asked for runs.
297 pub fn decide(&self, name: &str, arguments: &Value) -> Decision {
298 let Some(entry) = self.entries.get(name) else {
299 return Decision::Refuse(format!("There is no tool {name}."));
300 };
301 if let Origin::Mcp { actions, default_action } = &entry.origin {
302 let action = arguments["action"].as_str().map(str::to_owned).or_else(|| default_action.clone());
303 match action {
304 Some(action) if actions.is_empty() || actions.contains(&action) => {}
305 Some(action) => {
306 return Decision::Refuse(format!(
307 "{name} {action} is not something Ask can do: it reads g1t and hands work off, and changes nothing itself. Its actions: {}.",
308 actions.join(", ")
309 ));
310 }
311 None if actions.is_empty() => {}
312 None => return Decision::Refuse(format!("Give an action: one of {}.", actions.join(", "))),
313 }
314 }
315 match entry.kind {
316 Kind::Read => Decision::Run,
317 Kind::Dispatch => Decision::Confirm,
318 Kind::Change => Decision::Refuse(format!("{name} changes g1t, which Ask does not do.")),
319 }
320 }
321}
322
323// --- What the person watching sees ------------------------------------------
324
325/// The repository or workspace a call is about, as given.
326fn target(arguments: &Value) -> Option<String> {
327 let repo = arguments["repo"].as_str().or_else(|| arguments["repository"].as_str()).filter(|repo| repo.contains('/'));
328 repo.or_else(|| arguments["workspace"].as_str()).map(str::to_owned).filter(|target| !target.is_empty())
329}
330
331fn noun(tool: &str, plural: bool) -> String {
332 let (one, many) = match tool {
333 "pull_request" => ("pull request", "pull requests"),
334 "issue" => ("issue", "issues"),
335 "repository" => ("repository", "repositories"),
336 "workflow" => ("workflow", "workflows"),
337 "workspace" => ("workspace", "workspaces"),
338 "notifications" => ("notification", "notifications"),
339 "package" => ("package", "packages"),
340 "team" => ("team", "teams"),
341 "artifact" => ("artifact", "artifacts"),
342 "security" => ("security alert", "security alerts"),
343 "observability" => ("production telemetry", "production telemetry"),
344 other => return other.replace('_', " "),
345 };
346 (if plural { many } else { one }).to_owned()
347}
348
349/// One line saying what a call does: "Read pull request #7 in acme/web".
350pub fn label(tool: &str, arguments: &Value) -> String {
351 let action = arguments["action"].as_str().unwrap_or_default();
352 let place = target(arguments).map(|target| format!(" in {target}")).unwrap_or_default();
353 if tool == "search" && action != "ticket" {
354 let query = arguments["query"].as_str().or_else(|| arguments["q"].as_str()).unwrap_or_default();
355 let query: String = query.chars().take(80).collect();
356 return format!("Searched g1t for \u{201c}{query}\u{201d}");
357 }
358 if tool == PROPOSE_SESSION {
359 if arguments["new_repository"].as_bool() == Some(true) {
360 let repo = arguments["repo"].as_str().unwrap_or("a new repository");
361 return format!("Proposed building {repo}, a new repository");
362 }
363 let what = arguments["issue"].as_u64().map(|number| format!("issue #{number}")).unwrap_or_else(|| "a new issue".to_owned());
364 let repo = arguments["repo"].as_str().unwrap_or("the repository");
365 return format!("Proposed a session on {what} in {repo}");
366 }
367 if tool == "workspace" && action == "activity" {
368 let since = arguments["since"].as_str().map(|since| format!(" since {}", since.trim_end_matches("T00:00:00Z"))).unwrap_or_default();
369 return format!("Read activity{place}{since}");
370 }
371 let number = arguments["number"].as_u64().map(|number| format!(" #{number}")).unwrap_or_default();
372 let (verb, rest) = match action {
373 "" | "get" => ("Read", None),
374 "list" => ("Listed", None),
375 _ => match action.split_once('_') {
376 Some(("list", rest)) => ("Listed", Some(rest)),
377 Some(("get", rest)) => ("Read", Some(rest)),
378 _ => ("Read", Some(action)),
379 },
380 };
381 let what = match rest {
382 None => noun(tool, action == "list" && number.is_empty()),
383 Some(rest) if number.is_empty() => rest.replace('_', " "),
384 Some(rest) => format!("{} {}", noun(tool, false), rest.replace('_', " ")),
385 };
386 // "Read pull request #7 changes" reads better as "Read changes of pull request #7".
387 let what = match (rest, number.is_empty()) {
388 (Some(rest), false) => format!("{} of {}{number}", rest.replace('_', " "), noun(tool, false)),
389 _ => format!("{what}{number}"),
390 };
391 format!("{verb} {what}{place}")
392}
393
394/// The page on the site a call is about, when it names one.
395pub fn link(site: &str, tool: &str, arguments: &Value) -> Option<String> {
396 let site = site.trim_end_matches('/');
397 let action = arguments["action"].as_str().unwrap_or_default();
398 if let Some(repo) = arguments["repo"].as_str().filter(|repo| repo.contains('/') && !repo.contains(' ')) {
399 let repo = repo.trim_matches('/');
400 let number = arguments["number"].as_u64();
401 return Some(match (tool, number) {
402 ("pull_request", Some(number)) => format!("{site}/{repo}/pull/{number}"),
403 ("pull_request", None) => format!("{site}/{repo}/pulls"),
404 ("issue", Some(number)) => format!("{site}/{repo}/issues/{number}"),
405 ("issue", None) => format!("{site}/{repo}/issues"),
406 ("workflow", _) => match arguments["run_id"].as_str().or_else(|| arguments["run_id"].as_u64().map(|_| "")) {
407 Some("") | None => format!("{site}/{repo}/actions"),
408 Some(run) => format!("{site}/{repo}/actions/runs/{run}"),
409 },
410 ("repository", _) if action.contains("release") => format!("{site}/{repo}/releases"),
411 _ => format!("{site}/{repo}"),
412 });
413 }
414 arguments["workspace"].as_str().filter(|slug| !slug.is_empty() && !slug.contains('/')).map(|slug| format!("{site}/{slug}"))
415}
416
417/// One line saying what a call found, for the person watching.
418pub fn summary(text: &str, failed: bool) -> String {
419 if failed {
420 let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or("It failed.");
421 return first.chars().take(160).collect();
422 }
423 let Ok(value) = serde_json::from_str::<Value>(text) else {
424 return "Done".to_owned();
425 };
426 let count = |n: usize| if n == 1 { "1 result".to_owned() } else { format!("{n} results") };
427 match &value {
428 Value::Array(items) => count(items.len()),
429 Value::Object(fields) => {
430 // The list an answer is about, where it has one.
431 for key in ["events", "items", "results", "pulls", "issues", "runs", "workflow_runs", "jobs", "chunks", "entries", "repositories", "threads"] {
432 if let Some(items) = fields.get(key).and_then(Value::as_array) {
433 return count(items.len());
434 }
435 }
436 match fields.get("title").and_then(Value::as_str) {
437 Some(title) => title.chars().take(120).collect(),
438 None => "Done".to_owned(),
439 }
440 }
441 _ => "Done".to_owned(),
442 }
443}
444
445// --- What the model is given --------------------------------------------------
446
447/// Keys that cost the model tokens and tell it nothing.
448const NOISE: &[&str] = &["avatar_url", "gravatar_id", "node_id", "avatar", "etag"];
449
450/// Which end of a long result matters: the start of a list (newest first),
451/// or the end of a log, where a failed job says why.
452#[derive(Clone, Copy, Debug, PartialEq, Eq)]
453pub enum Keep {
454 Start,
455 End,
456}
457
458impl Keep {
459 /// The end a call's result is read from.
460 pub fn of(tool: &str, arguments: &Value) -> Keep {
461 if tool == "workflow" && arguments["action"].as_str().is_some_and(|action| action.contains("log")) {
462 Keep::End
463 } else {
464 Keep::Start
465 }
466 }
467}
468
469/// A result as the model is given it: compact JSON, at most `max`
470/// characters. Long texts and long lists are trimmed first, each saying
471/// how much was left out, so what is kept is still whole and readable; only
472/// then is the text cut, with a note saying how to read the rest. `keep`
473/// says which end of a list or text to keep.
474pub fn trim_result(text: &str, max: usize, keep: Keep) -> String {
475 let max = max.max(200);
476 let Ok(value) = serde_json::from_str::<Value>(text) else {
477 return cut(text, max, keep);
478 };
479 let value = strip(value);
480 let compact = value.to_string();
481 if compact.chars().count() <= max {
482 return compact;
483 }
484 // Trim texts and lists, harder each time, until it fits.
485 for (chars, items) in [(4_000, 100), (1_500, 50), (600, 25), (300, 12), (160, 6), (100, 3)] {
486 let trimmed = shorten(&value, chars, items, keep).to_string();
487 if trimmed.chars().count() <= max {
488 return trimmed;
489 }
490 }
491 cut(&shorten(&value, 100, 3, keep).to_string(), max, keep)
492}
493
494fn cut(text: &str, max: usize, keep: Keep) -> String {
495 let total = text.chars().count();
496 if total <= max {
497 return text.to_owned();
498 }
499 let room = max.saturating_sub(120);
500 let left = total - room;
501 match keep {
502 Keep::Start => {
503 let kept: String = text.chars().take(room).collect();
504 format!("{kept}\n[Cut: {left} more characters not shown. Ask for less: a narrower filter, a shorter span or one item.]")
505 }
506 Keep::End => {
507 let kept: String = text.chars().skip(left).collect();
508 format!("[Cut: the first {left} characters not shown.]\n{kept}")
509 }
510 }
511}
512
513fn strip(value: Value) -> Value {
514 match value {
515 Value::Object(fields) => Value::Object(
516 fields
517 .into_iter()
518 .filter(|(key, value)| !NOISE.contains(&key.as_str()) && !value.as_str().is_some_and(|text| text.contains("{?")))
519 .map(|(key, value)| (key, strip(value)))
520 .collect(),
521 ),
522 Value::Array(items) => Value::Array(items.into_iter().map(strip).collect()),
523 other => other,
524 }
525}
526
527fn shorten(value: &Value, chars: usize, items: usize, keep: Keep) -> Value {
528 match value {
529 Value::String(text) if text.chars().count() > chars => {
530 let total = text.chars().count();
531 match keep {
532 Keep::Start => {
533 let kept: String = text.chars().take(chars).collect();
534 json!(format!("{kept}… [{} more characters]", total - chars))
535 }
536 Keep::End => {
537 let kept: String = text.chars().skip(total - chars).collect();
538 json!(format!("[{} characters before] …{kept}", total - chars))
539 }
540 }
541 }
542 Value::Array(list) if list.len() > items => {
543 let note = json!(format!("[{} more not shown: ask for fewer, or another page]", list.len() - items));
544 let shortened = |item: &Value| shorten(item, chars, items, keep);
545 match keep {
546 Keep::Start => {
547 let mut kept: Vec<Value> = list.iter().take(items).map(shortened).collect();
548 kept.push(note);
549 Value::Array(kept)
550 }
551 Keep::End => {
552 let mut kept = vec![note];
553 kept.extend(list.iter().skip(list.len() - items).map(shortened));
554 Value::Array(kept)
555 }
556 }
557 }
558 Value::Array(list) => Value::Array(list.iter().map(|item| shorten(item, chars, items, keep)).collect()),
559 Value::Object(fields) => Value::Object(
560 fields.iter().map(|(key, value)| (key.clone(), shorten(value, chars, items, keep))).collect::<Map<_, _>>(),
561 ),
562 other => other.clone(),
563 }
564}
565
566// --- Sources -------------------------------------------------------------------
567
568/// The pages on g1t an answer read, kept in the order first read.
569#[derive(Default)]
570pub struct Sources {
571 found: Vec<(String, String, bool)>,
572 seen: HashSet<String>,
573}
574
575/// The most pages kept from what one answer read.
576const MOST_READ: usize = 400;
577
578impl Sources {
579 fn add(&mut self, url: String, title: String, direct: bool) {
580 if self.found.len() >= MOST_READ {
581 return;
582 }
583 let url = url.trim_end_matches('/').to_owned();
584 if self.seen.insert(url.clone()) {
585 self.found.push((url, title, direct));
586 } else if direct && let Some(found) = self.found.iter_mut().find(|found| found.0 == url) {
587 found.2 = true;
588 }
589 }
590
591 /// Notes the pages a call's result names: each item's own `url` or
592 /// `html_url` on the site, a repository, and the issues and pull
593 /// requests of the repository it was about. `direct` for a call about
594 /// one thing (its link), which is a source whether cited or not.
595 pub fn read(&mut self, site: &str, tool: &str, arguments: &Value, text: &str, direct: Option<(&str, &str)>) {
596 let site = site.trim_end_matches('/');
597 if let Some((url, title)) = direct {
598 self.add(url.to_owned(), title.to_owned(), true);
599 }
600 let Ok(value) = serde_json::from_str::<Value>(text) else {
601 return;
602 };
603 let repo = arguments["repo"].as_str().filter(|repo| repo.contains('/')).map(|repo| repo.trim_matches('/').to_owned());
604 self.walk(site, tool, repo.as_deref(), &value, 0);
605 }
606
607 fn walk(&mut self, site: &str, tool: &str, repo: Option<&str>, value: &Value, depth: usize) {
608 if depth > 6 {
609 return;
610 }
611 match value {
612 Value::Array(items) => items.iter().for_each(|item| self.walk(site, tool, repo, item, depth + 1)),
613 Value::Object(fields) => {
614 let text = |key: &str| fields.get(key).and_then(Value::as_str).filter(|text| !text.is_empty());
615 let number = fields.get("number").and_then(Value::as_u64);
616 let title = text("title").or_else(|| text("name")).or_else(|| text("full_name")).or_else(|| text("tag"));
617 let own = text("html_url").or_else(|| text("url")).filter(|url| url.starts_with(site));
618 if let Some(url) = own {
619 let title = match (title, number) {
620 (Some(title), Some(number)) => format!("#{number} {title}"),
621 (Some(title), None) => title.to_owned(),
622 (None, _) => url.trim_start_matches(site).trim_start_matches('/').to_owned(),
623 };
624 self.add(url.to_owned(), title, false);
625 } else if let (Some(repo), Some(number), Some(title)) = (repo, number, text("title")) {
626 let section = if tool == "pull_request" || fields.contains_key("merged_at") { "pull" } else { "issues" };
627 self.add(format!("{site}/{repo}/{section}/{number}"), format!("#{number} {title}"), false);
628 } else if let (Some(namespace), Some(name)) = (text("namespace"), text("name"))
629 && fields.contains_key("default_branch")
630 {
631 self.add(format!("{site}/{namespace}/{name}"), format!("{namespace}/{name}"), false);
632 }
633 for (key, value) in fields {
634 if matches!(value, Value::Array(_) | Value::Object(_)) && key != "author" && key != "user" {
635 self.walk(site, tool, repo, value, depth + 1);
636 }
637 }
638 }
639 _ => {}
640 }
641 }
642
643 /// What the answer drew on: the pages it links to, in the order read,
644 /// then those it read directly, at most [`ASK_MAX_SOURCES`].
645 pub fn finish(&self, site: &str, answer: &str) -> Vec<AskSource> {
646 let site = site.trim_end_matches('/');
647 let cites = |url: &str| {
648 let path = url.trim_start_matches(site);
649 answer.contains(url) || (!path.is_empty() && path != "/" && contains_path(answer, path))
650 };
651 let mut sources: Vec<AskSource> = self
652 .found
653 .iter()
654 .filter(|(url, _, _)| cites(url))
655 .map(|(url, title, _)| AskSource { title: title.clone(), url: url.clone(), cited: true })
656 .collect();
657 for (url, title, direct) in &self.found {
658 if *direct && !sources.iter().any(|source| &source.url == url) {
659 sources.push(AskSource { title: title.clone(), url: url.clone(), cited: false });
660 }
661 }
662 sources.truncate(ASK_MAX_SOURCES);
663 sources
664 }
665}
666
667/// Whether `answer` names `path` as a whole path, not the start of a
668/// longer one (`/acme/web/pull/7` is not cited by `/acme/web/pull/70`).
669fn contains_path(answer: &str, path: &str) -> bool {
670 answer.match_indices(path).any(|(at, _)| {
671 let next = answer[at + path.len()..].chars().next();
672 !next.is_some_and(|c| c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_')
673 })
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 fn listing() -> Value {
681 json!({ "tools": [
682 {
683 "name": "pull_request",
684 "description": "Pull requests",
685 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "changes"] } }, "required": ["action"] },
686 "annotations": { "readOnlyHint": true },
687 },
688 {
689 "name": "search",
690 "description": "Find things",
691 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["code", "ticket"], "default": "code" } } },
692 "annotations": { "readOnlyHint": true },
693 },
694 {
695 "name": "issue",
696 "description": "Issues",
697 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "create"] } } },
698 "annotations": { "readOnlyHint": false },
699 },
700 ] })
701 }
702
703 #[test]
704 fn the_servers_tools_are_read_as_they_are_listed() {
705 let tools = listed(&listing());
706 assert_eq!(tools.len(), 3);
707 assert_eq!(tools[0].spec.name, "pull_request");
708 assert_eq!(tools[0].spec.input_schema["required"], json!(["action"]));
709 assert_eq!(tools[0].actions, vec!["list", "get", "changes"]);
710 assert_eq!(tools[1].default_action.as_deref(), Some("code"));
711 assert!(!tools[2].read_only);
712 assert!(listed(&json!({})).is_empty());
713 }
714
715 #[test]
716 fn ask_runs_reads_and_refuses_everything_else() {
717 let toolbox = Toolbox::new(listed(&listing()), native());
718 // A tool that is not read-only is not offered at all.
719 let names: Vec<&str> = toolbox.specs().iter().map(|spec| spec.name.as_str()).collect();
720 assert_eq!(names, vec!["pull_request", "search", PROPOSE_SESSION]);
721 assert_eq!(toolbox.decide("pull_request", &json!({ "action": "get", "repo": "a/b", "number": 1 })), Decision::Run);
722 assert_eq!(toolbox.decide("search", &json!({ "query": "x" })), Decision::Run, "the default action");
723 // An action the server did not list for this credential.
724 let Decision::Refuse(why) = toolbox.decide("pull_request", &json!({ "action": "merge" })) else { panic!() };
725 assert!(why.contains("changes nothing itself"), "{why}");
726 assert!(matches!(toolbox.decide("pull_request", &json!({})), Decision::Refuse(_)));
727 assert!(matches!(toolbox.decide("issue", &json!({ "action": "create" })), Decision::Refuse(_)));
728 assert!(matches!(toolbox.decide("nope", &json!({})), Decision::Refuse(_)));
729 }
730
731 #[test]
732 fn a_dispatch_waits_for_the_persons_yes() {
733 let start = Native {
734 spec: ToolSpec { name: "start_session".into(), description: "Start a session".into(), input_schema: json!({ "type": "object" }) },
735 kind: Kind::Dispatch,
736 };
737 let toolbox = Toolbox::new(Vec::new(), vec![start]);
738 assert!(toolbox.is_native("start_session"));
739 assert_eq!(toolbox.decide("start_session", &json!({ "repo": "a/b", "number": 3 })), Decision::Confirm);
740 }
741
742 #[test]
743 fn a_proposed_session_is_a_card_the_person_starts() {
744 let toolbox = Toolbox::new(Vec::new(), native());
745 assert!(toolbox.is_native(PROPOSE_SESSION));
746 let on_issue = json!({ "repo": "acme/web", "issue": 12, "mode": "implement", "title": "ignored", "instruction": " use the new name " });
747 assert_eq!(toolbox.decide(PROPOSE_SESSION, &on_issue), Decision::Confirm);
748 let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction, new_repository, deploy, .. } =
749 proposal("proposal_0_0", &on_issue).unwrap()
750 else {
751 panic!()
752 };
753 assert_eq!((id.as_str(), repo.as_str(), issue, mode.as_str()), ("proposal_0_0", "acme/web", Some(12), "implement"));
754 assert_eq!((title, body, instruction.as_deref()), (None, None, Some("use the new name")));
755 assert!(!new_repository && !deploy, "an existing repository is left as it is");
756 let new_issue = json!({ "repo": "acme/web", "title": "Add a collaborators route", "body": "List them.", "mode": "plan" });
757 let AskEvent::Proposal { issue, title, .. } = proposal("p", &new_issue).unwrap() else { panic!() };
758 assert_eq!((issue, title.as_deref()), (None, Some("Add a collaborators route")));
759 assert!(proposal("p", &json!({ "repo": "acme/web", "mode": "plan" })).is_err(), "no issue and no title");
760 assert!(proposal("p", &json!({ "repo": "acme", "issue": 1, "mode": "plan" })).is_err(), "not owner/name");
761 assert!(proposal("p", &json!({ "repo": "acme/web", "issue": 1, "mode": "ship" })).is_err(), "no such mode");
762 assert_eq!(label(PROPOSE_SESSION, &on_issue), "Proposed a session on issue #12 in acme/web");
763 }
764
765 #[test]
766 fn a_new_repository_is_created_deployed_and_built_from_one_card() {
767 let build = json!({ "repo": "acme/hello-world", "new_repository": true, "title": "Hello world", "body": "A page that says hello.", "mode": "implement" });
768 let AskEvent::Proposal { repo, issue, title, new_repository, private, deploy, .. } = proposal("p", &build).unwrap() else { panic!() };
769 assert_eq!((repo.as_str(), issue, title.as_deref()), ("acme/hello-world", None, Some("Hello world")));
770 assert!(new_repository && deploy && !private, "public and deployed unless asked otherwise");
771 let quiet = json!({ "repo": "acme/notes", "new_repository": true, "private": true, "deploy": false, "title": "Notes", "mode": "implement" });
772 let AskEvent::Proposal { private, deploy, .. } = proposal("p", &quiet).unwrap() else { panic!() };
773 assert!(private && !deploy);
774 assert!(proposal("p", &json!({ "repo": "acme/hello world", "new_repository": true, "title": "x", "mode": "implement" })).is_err());
775 assert!(proposal("p", &json!({ "repo": "acme/hello", "new_repository": true, "title": "x", "mode": "plan" })).is_err());
776 assert!(proposal("p", &json!({ "repo": "acme/hello", "new_repository": true, "issue": 1, "mode": "implement" })).is_err());
777 assert_eq!(label(PROPOSE_SESSION, &build), "Proposed building acme/hello-world, a new repository");
778 }
779
780 #[test]
781 fn steps_say_what_they_do_in_a_line() {
782 assert_eq!(label("pull_request", &json!({ "action": "list", "repo": "acme/web" })), "Listed pull requests in acme/web");
783 assert_eq!(label("pull_request", &json!({ "action": "get", "repo": "acme/web", "number": 7 })), "Read pull request #7 in acme/web");
784 assert_eq!(
785 label("pull_request", &json!({ "action": "changes", "repo": "acme/web", "number": 7 })),
786 "Read changes of pull request #7 in acme/web"
787 );
788 assert_eq!(label("workflow", &json!({ "action": "list_runs", "repo": "acme/web" })), "Listed runs in acme/web");
789 assert_eq!(label("workflow", &json!({ "action": "job_logs", "repo": "acme/web" })), "Read job logs in acme/web");
790 assert_eq!(
791 label("workspace", &json!({ "action": "activity", "workspace": "acme", "since": "2026-10-12T00:00:00Z" })),
792 "Read activity in acme since 2026-10-12"
793 );
794 assert_eq!(label("search", &json!({ "query": "login bug" })), "Searched g1t for \u{201c}login bug\u{201d}");
795 assert_eq!(label("repository", &json!({ "action": "list" })), "Listed repositories");
796 }
797
798 #[test]
799 fn steps_link_to_the_page_they_read() {
800 let site = "https://g1t.sh/";
801 assert_eq!(link(site, "pull_request", &json!({ "repo": "acme/web", "number": 7 })).unwrap(), "https://g1t.sh/acme/web/pull/7");
802 assert_eq!(link(site, "issue", &json!({ "action": "list", "repo": "acme/web" })).unwrap(), "https://g1t.sh/acme/web/issues");
803 assert_eq!(link(site, "workflow", &json!({ "action": "get_run", "repo": "acme/web", "run_id": "run_1" })).unwrap(), "https://g1t.sh/acme/web/actions/runs/run_1");
804 assert_eq!(link(site, "workspace", &json!({ "action": "activity", "workspace": "acme" })).unwrap(), "https://g1t.sh/acme");
805 assert_eq!(link(site, "search", &json!({ "query": "x" })), None);
806 }
807
808 #[test]
809 fn steps_say_what_they_found() {
810 assert_eq!(summary("[1,2,3]", false), "3 results");
811 assert_eq!(summary("{\"events\":[{}],\"summary\":{}}", false), "1 result");
812 assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login");
813 assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write");
814 assert_eq!(summary("plain", false), "Done");
815 }
816
817 #[test]
818 fn big_results_are_trimmed_not_dropped() {
819 let small = json!({ "title": "x", "avatar_url": "https://a", "url_template": "https://x/{?q}" }).to_string();
820 assert_eq!(trim_result(&small, 1_000, Keep::Start), "{\"title\":\"x\"}");
821 // A long list keeps its first items, whole, and says how many more.
822 let items: Vec<Value> = (0..400).map(|n| json!({ "number": n, "title": format!("Pull request {n}"), "body": "b".repeat(3_000) })).collect();
823 let big = json!({ "items": items }).to_string();
824 let kept = trim_result(&big, 16_000, Keep::Start);
825 assert!(kept.chars().count() <= 16_000, "{}", kept.len());
826 let read: Value = serde_json::from_str(&kept).expect("still JSON");
827 let list = read["items"].as_array().unwrap();
828 assert_eq!(list[0]["number"], 0);
829 assert!(list.last().unwrap().as_str().unwrap().contains("more not shown"));
830 assert!(list[0]["body"].as_str().unwrap().contains("more characters"));
831 // Text that is not JSON is cut with a note.
832 let log = "line\n".repeat(10_000);
833 let kept = trim_result(&log, 1_000, Keep::Start);
834 assert!(kept.chars().count() <= 1_000);
835 assert!(kept.contains("[Cut:"));
836 }
837
838 #[test]
839 fn a_logs_end_is_kept_where_a_failure_says_why() {
840 assert_eq!(Keep::of("workflow", &json!({ "action": "job_logs" })), Keep::End);
841 assert_eq!(Keep::of("workflow", &json!({ "action": "list_runs" })), Keep::Start);
842 assert_eq!(Keep::of("pull_request", &json!({ "action": "list" })), Keep::Start);
843 let mut chunks: Vec<Value> = (0..2_000).map(|seq| json!({ "seq": seq, "step": 3, "text": format!("building part {seq}\n") })).collect();
844 chunks.push(json!({ "seq": 2_000, "step": 4, "text": "##[error]No space left on device\n" }));
845 let log = json!({ "chunks": chunks, "done": true }).to_string();
846 let kept = trim_result(&log, 4_000, Keep::End);
847 assert!(kept.chars().count() <= 4_000);
848 let read: Value = serde_json::from_str(&kept).expect("still JSON");
849 let kept_chunks = read["chunks"].as_array().unwrap();
850 assert!(kept_chunks[0].as_str().unwrap().contains("more not shown"));
851 assert!(kept_chunks.last().unwrap()["text"].as_str().unwrap().contains("No space left on device"));
852 // Plain text keeps its end too.
853 let text = format!("{}error: disk full", "noise\n".repeat(1_000));
854 let kept = trim_result(&text, 500, Keep::End);
855 assert!(kept.ends_with("error: disk full"));
856 assert!(kept.starts_with("[Cut: the first"));
857 }
858
859 #[test]
860 fn sources_are_the_pages_the_answer_cites_then_those_it_read() {
861 let site = "https://g1t.sh";
862 let mut sources = Sources::default();
863 let activity = json!({ "events": [
864 { "kind": "pull_merged", "repo": "acme/web", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7" },
865 { "kind": "release_published", "repo": "acme/api", "tag": "v1.2.0", "title": "Faster", "url": "https://g1t.sh/acme/api/releases/tag/v1.2.0" },
866 { "kind": "pull_merged", "repo": "acme/web", "number": 70, "title": "Other", "url": "https://g1t.sh/acme/web/pull/70" },
867 ] });
868 sources.read(site, "workspace", &json!({ "action": "activity", "workspace": "acme" }), &activity.to_string(), None);
869 let list = json!([{ "number": 9, "title": "Docs", "merged_at": null }]);
870 sources.read(site, "pull_request", &json!({ "action": "list", "repo": "acme/docs" }), &list.to_string(), None);
871 sources.read(
872 site,
873 "issue",
874 &json!({ "action": "get", "repo": "acme/web", "number": 3 }),
875 "{\"issue\":{\"title\":\"Login fails\"}}",
876 Some(("https://g1t.sh/acme/web/issues/3", "Read issue #3 in acme/web")),
877 );
878 let answer = "Shipped: [Fix login](https://g1t.sh/acme/web/pull/7) and acme/docs [#9](/acme/docs/pull/9).";
879 let found = sources.finish(site, answer);
880 assert_eq!(
881 found,
882 vec![
883 AskSource { title: "#7 Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true },
884 AskSource { title: "#9 Docs".into(), url: "https://g1t.sh/acme/docs/pull/9".into(), cited: true },
885 AskSource { title: "Read issue #3 in acme/web".into(), url: "https://g1t.sh/acme/web/issues/3".into(), cited: false },
886 ]
887 );
888 }
889}