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
852 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::integrations::{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."
192 .to_owned(),
193 input_schema: json!({
194 "type": "object",
195 "properties": {
196 "repo": { "type": "string", "description": "The repository, owner/name." },
197 "issue": { "type": "integer", "description": "The issue the work belongs to." },
198 "title": { "type": "string", "description": "A new issue's title, when there is no issue yet." },
199 "body": { "type": "string", "description": "A new issue's description: what to do and what done means." },
200 "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." },
201 "instruction": { "type": "string", "description": "Anything the session should know beyond the issue." }
202 },
203 "required": ["repo", "mode"]
204 }),
205 },
206 kind: Kind::Dispatch,
207 }]
208}
209
210/// A proposal from a `propose_session` call, or what is wrong with it.
211pub fn proposal(id: &str, arguments: &Value) -> Result<AskEvent, String> {
212 let text = |key: &str| arguments[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned);
213 let repo = text("repo").filter(|repo| repo.split('/').count() == 2).ok_or("Give the repository as owner/name.")?;
214 let issue = arguments["issue"].as_u64().filter(|number| *number > 0);
215 let title = text("title");
216 if issue.is_none() && title.is_none() {
217 return Err("Give the issue the work belongs to, or a title and body for a new one.".to_owned());
218 }
219 let mode = match text("mode").as_deref() {
220 Some(mode @ ("plan" | "implement" | "review")) => mode.to_owned(),
221 _ => return Err("Give a mode: plan, implement or review.".to_owned()),
222 };
223 let clip = |value: Option<String>, max: usize| value.map(|value| value.chars().take(max).collect::<String>());
224 Ok(AskEvent::Proposal {
225 id: id.to_owned(),
226 repo,
227 issue,
228 title: if issue.is_some() { None } else { clip(title, 200) },
229 body: if issue.is_some() { None } else { clip(text("body"), 8_000) },
230 mode,
231 instruction: clip(text("instruction"), 4_000),
232 })
233}
234
235/// Runs a g1t-native tool. `propose_session` never runs here: the loop
236/// turns it into a proposal.
237pub async fn run_native(name: &str, _arguments: &Value) -> Result<(String, bool), String> {
238 Err(format!("{name} is not something Ask can do."))
239}
240
241impl Toolbox {
242 /// From what the MCP server listed, and the native tools.
243 pub fn new(listed: Vec<Listed>, natives: Vec<Native>) -> Self {
244 let mut toolbox = Toolbox::default();
245 for tool in listed {
246 // A read-only credential lists reads only; a tool that says
247 // otherwise is not one Ask uses.
248 let kind = if tool.read_only { Kind::Read } else { Kind::Change };
249 if kind != Kind::Read {
250 continue;
251 }
252 toolbox.entries.insert(
253 tool.spec.name.clone(),
254 Entry { origin: Origin::Mcp { actions: tool.actions, default_action: tool.default_action }, kind },
255 );
256 toolbox.specs.push(tool.spec);
257 }
258 for tool in natives {
259 if toolbox.entries.contains_key(&tool.spec.name) {
260 continue;
261 }
262 toolbox.entries.insert(tool.spec.name.clone(), Entry { origin: Origin::Native, kind: tool.kind });
263 toolbox.specs.push(tool.spec);
264 }
265 toolbox
266 }
267
268 pub fn specs(&self) -> &[ToolSpec] {
269 &self.specs
270 }
271
272 pub fn is_empty(&self) -> bool {
273 self.specs.is_empty()
274 }
275
276 pub fn is_native(&self, name: &str) -> bool {
277 self.entries.get(name).is_some_and(|entry| entry.origin == Origin::Native)
278 }
279
280 /// Whether a call the model asked for runs.
281 pub fn decide(&self, name: &str, arguments: &Value) -> Decision {
282 let Some(entry) = self.entries.get(name) else {
283 return Decision::Refuse(format!("There is no tool {name}."));
284 };
285 if let Origin::Mcp { actions, default_action } = &entry.origin {
286 let action = arguments["action"].as_str().map(str::to_owned).or_else(|| default_action.clone());
287 match action {
288 Some(action) if actions.is_empty() || actions.contains(&action) => {}
289 Some(action) => {
290 return Decision::Refuse(format!(
291 "{name} {action} is not something Ask can do: it reads g1t and hands work off, and changes nothing itself. Its actions: {}.",
292 actions.join(", ")
293 ));
294 }
295 None if actions.is_empty() => {}
296 None => return Decision::Refuse(format!("Give an action: one of {}.", actions.join(", "))),
297 }
298 }
299 match entry.kind {
300 Kind::Read => Decision::Run,
301 Kind::Dispatch => Decision::Confirm,
302 Kind::Change => Decision::Refuse(format!("{name} changes g1t, which Ask does not do.")),
303 }
304 }
305}
306
307// --- What the person watching sees ------------------------------------------
308
309/// The repository or workspace a call is about, as given.
310fn target(arguments: &Value) -> Option<String> {
311 let repo = arguments["repo"].as_str().or_else(|| arguments["repository"].as_str()).filter(|repo| repo.contains('/'));
312 repo.or_else(|| arguments["workspace"].as_str()).map(str::to_owned).filter(|target| !target.is_empty())
313}
314
315fn noun(tool: &str, plural: bool) -> String {
316 let (one, many) = match tool {
317 "pull_request" => ("pull request", "pull requests"),
318 "issue" => ("issue", "issues"),
319 "repository" => ("repository", "repositories"),
320 "workflow" => ("workflow", "workflows"),
321 "workspace" => ("workspace", "workspaces"),
322 "memory" => ("memory", "memory"),
323 "notifications" => ("notification", "notifications"),
324 "package" => ("package", "packages"),
325 "team" => ("team", "teams"),
326 "artifact" => ("artifact", "artifacts"),
327 "security" => ("security alert", "security alerts"),
328 "observability" => ("production telemetry", "production telemetry"),
329 other => return other.replace('_', " "),
330 };
331 (if plural { many } else { one }).to_owned()
332}
333
334/// One line saying what a call does: "Read pull request #7 in acme/web".
335pub fn label(tool: &str, arguments: &Value) -> String {
336 let action = arguments["action"].as_str().unwrap_or_default();
337 let place = target(arguments).map(|target| format!(" in {target}")).unwrap_or_default();
338 if tool == "search" && action != "ticket" {
339 let query = arguments["query"].as_str().or_else(|| arguments["q"].as_str()).unwrap_or_default();
340 let query: String = query.chars().take(80).collect();
341 return format!("Searched g1t for \u{201c}{query}\u{201d}");
342 }
343 if tool == PROPOSE_SESSION {
344 let what = arguments["issue"].as_u64().map(|number| format!("issue #{number}")).unwrap_or_else(|| "a new issue".to_owned());
345 let repo = arguments["repo"].as_str().unwrap_or("the repository");
346 return format!("Proposed a session on {what} in {repo}");
347 }
348 if tool == "workspace" && action == "activity" {
349 let since = arguments["since"].as_str().map(|since| format!(" since {}", since.trim_end_matches("T00:00:00Z"))).unwrap_or_default();
350 return format!("Read activity{place}{since}");
351 }
352 let number = arguments["number"].as_u64().map(|number| format!(" #{number}")).unwrap_or_default();
353 let (verb, rest) = match action {
354 "" | "get" => ("Read", None),
355 "list" => ("Listed", None),
356 _ => match action.split_once('_') {
357 Some(("list", rest)) => ("Listed", Some(rest)),
358 Some(("get", rest)) => ("Read", Some(rest)),
359 _ => ("Read", Some(action)),
360 },
361 };
362 let what = match rest {
363 None => noun(tool, action == "list" && number.is_empty()),
364 Some(rest) if number.is_empty() => rest.replace('_', " "),
365 Some(rest) => format!("{} {}", noun(tool, false), rest.replace('_', " ")),
366 };
367 // "Read pull request #7 changes" reads better as "Read changes of pull request #7".
368 let what = match (rest, number.is_empty()) {
369 (Some(rest), false) => format!("{} of {}{number}", rest.replace('_', " "), noun(tool, false)),
370 _ => format!("{what}{number}"),
371 };
372 format!("{verb} {what}{place}")
373}
374
375/// The page on the site a call is about, when it names one.
376pub fn link(site: &str, tool: &str, arguments: &Value) -> Option<String> {
377 let site = site.trim_end_matches('/');
378 let action = arguments["action"].as_str().unwrap_or_default();
379 if let Some(repo) = arguments["repo"].as_str().filter(|repo| repo.contains('/') && !repo.contains(' ')) {
380 let repo = repo.trim_matches('/');
381 let number = arguments["number"].as_u64();
382 return Some(match (tool, number) {
383 ("pull_request", Some(number)) => format!("{site}/{repo}/pull/{number}"),
384 ("pull_request", None) => format!("{site}/{repo}/pulls"),
385 ("issue", Some(number)) => format!("{site}/{repo}/issues/{number}"),
386 ("issue", None) => format!("{site}/{repo}/issues"),
387 ("workflow", _) => match arguments["run_id"].as_str().or_else(|| arguments["run_id"].as_u64().map(|_| "")) {
388 Some("") | None => format!("{site}/{repo}/actions"),
389 Some(run) => format!("{site}/{repo}/actions/runs/{run}"),
390 },
391 ("repository", _) if action.contains("release") => format!("{site}/{repo}/releases"),
392 _ => format!("{site}/{repo}"),
393 });
394 }
395 arguments["workspace"].as_str().filter(|slug| !slug.is_empty() && !slug.contains('/')).map(|slug| format!("{site}/{slug}"))
396}
397
398/// One line saying what a call found, for the person watching.
399pub fn summary(text: &str, failed: bool) -> String {
400 if failed {
401 let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or("It failed.");
402 return first.chars().take(160).collect();
403 }
404 let Ok(value) = serde_json::from_str::<Value>(text) else {
405 return "Done".to_owned();
406 };
407 let count = |n: usize| if n == 1 { "1 result".to_owned() } else { format!("{n} results") };
408 match &value {
409 Value::Array(items) => count(items.len()),
410 Value::Object(fields) => {
411 // The list an answer is about, where it has one.
412 for key in ["events", "items", "results", "pulls", "issues", "runs", "workflow_runs", "jobs", "chunks", "entries", "repositories", "threads"] {
413 if let Some(items) = fields.get(key).and_then(Value::as_array) {
414 return count(items.len());
415 }
416 }
417 match fields.get("title").and_then(Value::as_str) {
418 Some(title) => title.chars().take(120).collect(),
419 None => "Done".to_owned(),
420 }
421 }
422 _ => "Done".to_owned(),
423 }
424}
425
426// --- What the model is given --------------------------------------------------
427
428/// Keys that cost the model tokens and tell it nothing.
429const NOISE: &[&str] = &["avatar_url", "gravatar_id", "node_id", "avatar", "etag"];
430
431/// Which end of a long result matters: the start of a list (newest first),
432/// or the end of a log, where a failed job says why.
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub enum Keep {
435 Start,
436 End,
437}
438
439impl Keep {
440 /// The end a call's result is read from.
441 pub fn of(tool: &str, arguments: &Value) -> Keep {
442 if tool == "workflow" && arguments["action"].as_str().is_some_and(|action| action.contains("log")) {
443 Keep::End
444 } else {
445 Keep::Start
446 }
447 }
448}
449
450/// A result as the model is given it: compact JSON, at most `max`
451/// characters. Long texts and long lists are trimmed first, each saying
452/// how much was left out, so what is kept is still whole and readable; only
453/// then is the text cut, with a note saying how to read the rest. `keep`
454/// says which end of a list or text to keep.
455pub fn trim_result(text: &str, max: usize, keep: Keep) -> String {
456 let max = max.max(200);
457 let Ok(value) = serde_json::from_str::<Value>(text) else {
458 return cut(text, max, keep);
459 };
460 let value = strip(value);
461 let compact = value.to_string();
462 if compact.chars().count() <= max {
463 return compact;
464 }
465 // Trim texts and lists, harder each time, until it fits.
466 for (chars, items) in [(4_000, 100), (1_500, 50), (600, 25), (300, 12), (160, 6), (100, 3)] {
467 let trimmed = shorten(&value, chars, items, keep).to_string();
468 if trimmed.chars().count() <= max {
469 return trimmed;
470 }
471 }
472 cut(&shorten(&value, 100, 3, keep).to_string(), max, keep)
473}
474
475fn cut(text: &str, max: usize, keep: Keep) -> String {
476 let total = text.chars().count();
477 if total <= max {
478 return text.to_owned();
479 }
480 let room = max.saturating_sub(120);
481 let left = total - room;
482 match keep {
483 Keep::Start => {
484 let kept: String = text.chars().take(room).collect();
485 format!("{kept}\n[Cut: {left} more characters not shown. Ask for less: a narrower filter, a shorter span or one item.]")
486 }
487 Keep::End => {
488 let kept: String = text.chars().skip(left).collect();
489 format!("[Cut: the first {left} characters not shown.]\n{kept}")
490 }
491 }
492}
493
494fn strip(value: Value) -> Value {
495 match value {
496 Value::Object(fields) => Value::Object(
497 fields
498 .into_iter()
499 .filter(|(key, value)| !NOISE.contains(&key.as_str()) && !value.as_str().is_some_and(|text| text.contains("{?")))
500 .map(|(key, value)| (key, strip(value)))
501 .collect(),
502 ),
503 Value::Array(items) => Value::Array(items.into_iter().map(strip).collect()),
504 other => other,
505 }
506}
507
508fn shorten(value: &Value, chars: usize, items: usize, keep: Keep) -> Value {
509 match value {
510 Value::String(text) if text.chars().count() > chars => {
511 let total = text.chars().count();
512 match keep {
513 Keep::Start => {
514 let kept: String = text.chars().take(chars).collect();
515 json!(format!("{kept}… [{} more characters]", total - chars))
516 }
517 Keep::End => {
518 let kept: String = text.chars().skip(total - chars).collect();
519 json!(format!("[{} characters before] …{kept}", total - chars))
520 }
521 }
522 }
523 Value::Array(list) if list.len() > items => {
524 let note = json!(format!("[{} more not shown: ask for fewer, or another page]", list.len() - items));
525 let shortened = |item: &Value| shorten(item, chars, items, keep);
526 match keep {
527 Keep::Start => {
528 let mut kept: Vec<Value> = list.iter().take(items).map(shortened).collect();
529 kept.push(note);
530 Value::Array(kept)
531 }
532 Keep::End => {
533 let mut kept = vec![note];
534 kept.extend(list.iter().skip(list.len() - items).map(shortened));
535 Value::Array(kept)
536 }
537 }
538 }
539 Value::Array(list) => Value::Array(list.iter().map(|item| shorten(item, chars, items, keep)).collect()),
540 Value::Object(fields) => Value::Object(
541 fields.iter().map(|(key, value)| (key.clone(), shorten(value, chars, items, keep))).collect::<Map<_, _>>(),
542 ),
543 other => other.clone(),
544 }
545}
546
547// --- Sources -------------------------------------------------------------------
548
549/// The pages on g1t an answer read, kept in the order first read.
550#[derive(Default)]
551pub struct Sources {
552 found: Vec<(String, String, bool)>,
553 seen: HashSet<String>,
554}
555
556/// The most pages kept from what one answer read.
557const MOST_READ: usize = 400;
558
559impl Sources {
560 fn add(&mut self, url: String, title: String, direct: bool) {
561 if self.found.len() >= MOST_READ {
562 return;
563 }
564 let url = url.trim_end_matches('/').to_owned();
565 if self.seen.insert(url.clone()) {
566 self.found.push((url, title, direct));
567 } else if direct && let Some(found) = self.found.iter_mut().find(|found| found.0 == url) {
568 found.2 = true;
569 }
570 }
571
572 /// Notes the pages a call's result names: each item's own `url` or
573 /// `html_url` on the site, a repository, and the issues and pull
574 /// requests of the repository it was about. `direct` for a call about
575 /// one thing (its link), which is a source whether cited or not.
576 pub fn read(&mut self, site: &str, tool: &str, arguments: &Value, text: &str, direct: Option<(&str, &str)>) {
577 let site = site.trim_end_matches('/');
578 if let Some((url, title)) = direct {
579 self.add(url.to_owned(), title.to_owned(), true);
580 }
581 let Ok(value) = serde_json::from_str::<Value>(text) else {
582 return;
583 };
584 let repo = arguments["repo"].as_str().filter(|repo| repo.contains('/')).map(|repo| repo.trim_matches('/').to_owned());
585 self.walk(site, tool, repo.as_deref(), &value, 0);
586 }
587
588 fn walk(&mut self, site: &str, tool: &str, repo: Option<&str>, value: &Value, depth: usize) {
589 if depth > 6 {
590 return;
591 }
592 match value {
593 Value::Array(items) => items.iter().for_each(|item| self.walk(site, tool, repo, item, depth + 1)),
594 Value::Object(fields) => {
595 let text = |key: &str| fields.get(key).and_then(Value::as_str).filter(|text| !text.is_empty());
596 let number = fields.get("number").and_then(Value::as_u64);
597 let title = text("title").or_else(|| text("name")).or_else(|| text("full_name")).or_else(|| text("tag"));
598 let own = text("html_url").or_else(|| text("url")).filter(|url| url.starts_with(site));
599 if let Some(url) = own {
600 let title = match (title, number) {
601 (Some(title), Some(number)) => format!("#{number} {title}"),
602 (Some(title), None) => title.to_owned(),
603 (None, _) => url.trim_start_matches(site).trim_start_matches('/').to_owned(),
604 };
605 self.add(url.to_owned(), title, false);
606 } else if let (Some(repo), Some(number), Some(title)) = (repo, number, text("title")) {
607 let section = if tool == "pull_request" || fields.contains_key("merged_at") { "pull" } else { "issues" };
608 self.add(format!("{site}/{repo}/{section}/{number}"), format!("#{number} {title}"), false);
609 } else if let (Some(namespace), Some(name)) = (text("namespace"), text("name"))
610 && fields.contains_key("default_branch")
611 {
612 self.add(format!("{site}/{namespace}/{name}"), format!("{namespace}/{name}"), false);
613 }
614 for (key, value) in fields {
615 if matches!(value, Value::Array(_) | Value::Object(_)) && key != "author" && key != "user" {
616 self.walk(site, tool, repo, value, depth + 1);
617 }
618 }
619 }
620 _ => {}
621 }
622 }
623
624 /// What the answer drew on: the pages it links to, in the order read,
625 /// then those it read directly, at most [`ASK_MAX_SOURCES`].
626 pub fn finish(&self, site: &str, answer: &str) -> Vec<AskSource> {
627 let site = site.trim_end_matches('/');
628 let cites = |url: &str| {
629 let path = url.trim_start_matches(site);
630 answer.contains(url) || (!path.is_empty() && path != "/" && contains_path(answer, path))
631 };
632 let mut sources: Vec<AskSource> = self
633 .found
634 .iter()
635 .filter(|(url, _, _)| cites(url))
636 .map(|(url, title, _)| AskSource { title: title.clone(), url: url.clone(), cited: true })
637 .collect();
638 for (url, title, direct) in &self.found {
639 if *direct && !sources.iter().any(|source| &source.url == url) {
640 sources.push(AskSource { title: title.clone(), url: url.clone(), cited: false });
641 }
642 }
643 sources.truncate(ASK_MAX_SOURCES);
644 sources
645 }
646}
647
648/// Whether `answer` names `path` as a whole path, not the start of a
649/// longer one (`/acme/web/pull/7` is not cited by `/acme/web/pull/70`).
650fn contains_path(answer: &str, path: &str) -> bool {
651 answer.match_indices(path).any(|(at, _)| {
652 let next = answer[at + path.len()..].chars().next();
653 !next.is_some_and(|c| c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_')
654 })
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn listing() -> Value {
662 json!({ "tools": [
663 {
664 "name": "pull_request",
665 "description": "Pull requests",
666 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "changes"] } }, "required": ["action"] },
667 "annotations": { "readOnlyHint": true },
668 },
669 {
670 "name": "search",
671 "description": "Find things",
672 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["code", "ticket"], "default": "code" } } },
673 "annotations": { "readOnlyHint": true },
674 },
675 {
676 "name": "issue",
677 "description": "Issues",
678 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "create"] } } },
679 "annotations": { "readOnlyHint": false },
680 },
681 ] })
682 }
683
684 #[test]
685 fn the_servers_tools_are_read_as_they_are_listed() {
686 let tools = listed(&listing());
687 assert_eq!(tools.len(), 3);
688 assert_eq!(tools[0].spec.name, "pull_request");
689 assert_eq!(tools[0].spec.input_schema["required"], json!(["action"]));
690 assert_eq!(tools[0].actions, vec!["list", "get", "changes"]);
691 assert_eq!(tools[1].default_action.as_deref(), Some("code"));
692 assert!(!tools[2].read_only);
693 assert!(listed(&json!({})).is_empty());
694 }
695
696 #[test]
697 fn ask_runs_reads_and_refuses_everything_else() {
698 let toolbox = Toolbox::new(listed(&listing()), native());
699 // A tool that is not read-only is not offered at all.
700 let names: Vec<&str> = toolbox.specs().iter().map(|spec| spec.name.as_str()).collect();
701 assert_eq!(names, vec!["pull_request", "search", PROPOSE_SESSION]);
702 assert_eq!(toolbox.decide("pull_request", &json!({ "action": "get", "repo": "a/b", "number": 1 })), Decision::Run);
703 assert_eq!(toolbox.decide("search", &json!({ "query": "x" })), Decision::Run, "the default action");
704 // An action the server did not list for this credential.
705 let Decision::Refuse(why) = toolbox.decide("pull_request", &json!({ "action": "merge" })) else { panic!() };
706 assert!(why.contains("changes nothing itself"), "{why}");
707 assert!(matches!(toolbox.decide("pull_request", &json!({})), Decision::Refuse(_)));
708 assert!(matches!(toolbox.decide("issue", &json!({ "action": "create" })), Decision::Refuse(_)));
709 assert!(matches!(toolbox.decide("nope", &json!({})), Decision::Refuse(_)));
710 }
711
712 #[test]
713 fn a_dispatch_waits_for_the_persons_yes() {
714 let start = Native {
715 spec: ToolSpec { name: "start_session".into(), description: "Start a session".into(), input_schema: json!({ "type": "object" }) },
716 kind: Kind::Dispatch,
717 };
718 let toolbox = Toolbox::new(Vec::new(), vec![start]);
719 assert!(toolbox.is_native("start_session"));
720 assert_eq!(toolbox.decide("start_session", &json!({ "repo": "a/b", "number": 3 })), Decision::Confirm);
721 }
722
723 #[test]
724 fn a_proposed_session_is_a_card_the_person_starts() {
725 let toolbox = Toolbox::new(Vec::new(), native());
726 assert!(toolbox.is_native(PROPOSE_SESSION));
727 let on_issue = json!({ "repo": "acme/web", "issue": 12, "mode": "implement", "title": "ignored", "instruction": " use the new name " });
728 assert_eq!(toolbox.decide(PROPOSE_SESSION, &on_issue), Decision::Confirm);
729 let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction } = proposal("proposal_0_0", &on_issue).unwrap() else {
730 panic!()
731 };
732 assert_eq!((id.as_str(), repo.as_str(), issue, mode.as_str()), ("proposal_0_0", "acme/web", Some(12), "implement"));
733 assert_eq!((title, body, instruction.as_deref()), (None, None, Some("use the new name")));
734 let new_issue = json!({ "repo": "acme/web", "title": "Add a collaborators route", "body": "List them.", "mode": "plan" });
735 let AskEvent::Proposal { issue, title, .. } = proposal("p", &new_issue).unwrap() else { panic!() };
736 assert_eq!((issue, title.as_deref()), (None, Some("Add a collaborators route")));
737 assert!(proposal("p", &json!({ "repo": "acme/web", "mode": "plan" })).is_err(), "no issue and no title");
738 assert!(proposal("p", &json!({ "repo": "acme", "issue": 1, "mode": "plan" })).is_err(), "not owner/name");
739 assert!(proposal("p", &json!({ "repo": "acme/web", "issue": 1, "mode": "ship" })).is_err(), "no such mode");
740 assert_eq!(label(PROPOSE_SESSION, &on_issue), "Proposed a session on issue #12 in acme/web");
741 }
742
743 #[test]
744 fn steps_say_what_they_do_in_a_line() {
745 assert_eq!(label("pull_request", &json!({ "action": "list", "repo": "acme/web" })), "Listed pull requests in acme/web");
746 assert_eq!(label("pull_request", &json!({ "action": "get", "repo": "acme/web", "number": 7 })), "Read pull request #7 in acme/web");
747 assert_eq!(
748 label("pull_request", &json!({ "action": "changes", "repo": "acme/web", "number": 7 })),
749 "Read changes of pull request #7 in acme/web"
750 );
751 assert_eq!(label("workflow", &json!({ "action": "list_runs", "repo": "acme/web" })), "Listed runs in acme/web");
752 assert_eq!(label("workflow", &json!({ "action": "job_logs", "repo": "acme/web" })), "Read job logs in acme/web");
753 assert_eq!(
754 label("workspace", &json!({ "action": "activity", "workspace": "acme", "since": "2026-10-12T00:00:00Z" })),
755 "Read activity in acme since 2026-10-12"
756 );
757 assert_eq!(label("search", &json!({ "query": "login bug" })), "Searched g1t for \u{201c}login bug\u{201d}");
758 assert_eq!(label("repository", &json!({ "action": "list" })), "Listed repositories");
759 }
760
761 #[test]
762 fn steps_link_to_the_page_they_read() {
763 let site = "https://g1t.sh/";
764 assert_eq!(link(site, "pull_request", &json!({ "repo": "acme/web", "number": 7 })).unwrap(), "https://g1t.sh/acme/web/pull/7");
765 assert_eq!(link(site, "issue", &json!({ "action": "list", "repo": "acme/web" })).unwrap(), "https://g1t.sh/acme/web/issues");
766 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");
767 assert_eq!(link(site, "workspace", &json!({ "action": "activity", "workspace": "acme" })).unwrap(), "https://g1t.sh/acme");
768 assert_eq!(link(site, "search", &json!({ "query": "x" })), None);
769 }
770
771 #[test]
772 fn steps_say_what_they_found() {
773 assert_eq!(summary("[1,2,3]", false), "3 results");
774 assert_eq!(summary("{\"events\":[{}],\"summary\":{}}", false), "1 result");
775 assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login");
776 assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write");
777 assert_eq!(summary("plain", false), "Done");
778 }
779
780 #[test]
781 fn big_results_are_trimmed_not_dropped() {
782 let small = json!({ "title": "x", "avatar_url": "https://a", "url_template": "https://x/{?q}" }).to_string();
783 assert_eq!(trim_result(&small, 1_000, Keep::Start), "{\"title\":\"x\"}");
784 // A long list keeps its first items, whole, and says how many more.
785 let items: Vec<Value> = (0..400).map(|n| json!({ "number": n, "title": format!("Pull request {n}"), "body": "b".repeat(3_000) })).collect();
786 let big = json!({ "items": items }).to_string();
787 let kept = trim_result(&big, 16_000, Keep::Start);
788 assert!(kept.chars().count() <= 16_000, "{}", kept.len());
789 let read: Value = serde_json::from_str(&kept).expect("still JSON");
790 let list = read["items"].as_array().unwrap();
791 assert_eq!(list[0]["number"], 0);
792 assert!(list.last().unwrap().as_str().unwrap().contains("more not shown"));
793 assert!(list[0]["body"].as_str().unwrap().contains("more characters"));
794 // Text that is not JSON is cut with a note.
795 let log = "line\n".repeat(10_000);
796 let kept = trim_result(&log, 1_000, Keep::Start);
797 assert!(kept.chars().count() <= 1_000);
798 assert!(kept.contains("[Cut:"));
799 }
800
801 #[test]
802 fn a_logs_end_is_kept_where_a_failure_says_why() {
803 assert_eq!(Keep::of("workflow", &json!({ "action": "job_logs" })), Keep::End);
804 assert_eq!(Keep::of("workflow", &json!({ "action": "list_runs" })), Keep::Start);
805 assert_eq!(Keep::of("pull_request", &json!({ "action": "list" })), Keep::Start);
806 let mut chunks: Vec<Value> = (0..2_000).map(|seq| json!({ "seq": seq, "step": 3, "text": format!("building part {seq}\n") })).collect();
807 chunks.push(json!({ "seq": 2_000, "step": 4, "text": "##[error]No space left on device\n" }));
808 let log = json!({ "chunks": chunks, "done": true }).to_string();
809 let kept = trim_result(&log, 4_000, Keep::End);
810 assert!(kept.chars().count() <= 4_000);
811 let read: Value = serde_json::from_str(&kept).expect("still JSON");
812 let kept_chunks = read["chunks"].as_array().unwrap();
813 assert!(kept_chunks[0].as_str().unwrap().contains("more not shown"));
814 assert!(kept_chunks.last().unwrap()["text"].as_str().unwrap().contains("No space left on device"));
815 // Plain text keeps its end too.
816 let text = format!("{}error: disk full", "noise\n".repeat(1_000));
817 let kept = trim_result(&text, 500, Keep::End);
818 assert!(kept.ends_with("error: disk full"));
819 assert!(kept.starts_with("[Cut: the first"));
820 }
821
822 #[test]
823 fn sources_are_the_pages_the_answer_cites_then_those_it_read() {
824 let site = "https://g1t.sh";
825 let mut sources = Sources::default();
826 let activity = json!({ "events": [
827 { "kind": "pull_merged", "repo": "acme/web", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7" },
828 { "kind": "release_published", "repo": "acme/api", "tag": "v1.2.0", "title": "Faster", "url": "https://g1t.sh/acme/api/releases/tag/v1.2.0" },
829 { "kind": "pull_merged", "repo": "acme/web", "number": 70, "title": "Other", "url": "https://g1t.sh/acme/web/pull/70" },
830 ] });
831 sources.read(site, "workspace", &json!({ "action": "activity", "workspace": "acme" }), &activity.to_string(), None);
832 let list = json!([{ "number": 9, "title": "Docs", "merged_at": null }]);
833 sources.read(site, "pull_request", &json!({ "action": "list", "repo": "acme/docs" }), &list.to_string(), None);
834 sources.read(
835 site,
836 "issue",
837 &json!({ "action": "get", "repo": "acme/web", "number": 3 }),
838 "{\"issue\":{\"title\":\"Login fails\"}}",
839 Some(("https://g1t.sh/acme/web/issues/3", "Read issue #3 in acme/web")),
840 );
841 let answer = "Shipped: [Fix login](https://g1t.sh/acme/web/pull/7) and acme/docs [#9](/acme/docs/pull/9).";
842 let found = sources.finish(site, answer);
843 assert_eq!(
844 found,
845 vec![
846 AskSource { title: "#7 Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true },
847 AskSource { title: "#9 Docs".into(), url: "https://g1t.sh/acme/docs/pull/9".into(), cited: true },
848 AskSource { title: "Read issue #3 in acme/web".into(), url: "https://g1t.sh/acme/web/issues/3".into(), cited: false },
849 ]
850 );
851 }
852}