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
900 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/// Whether a tool's error says only that what it looked for isn't there:
418/// a finding, such as a new repository's name being free, not a failed read.
419pub fn is_not_found(text: &str) -> bool {
420 let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().to_ascii_lowercase();
421 first.contains("not found") || first.starts_with("there is no ") || first.starts_with("no such ")
422}
423
424/// One line saying what a call found, for the person watching.
425pub fn summary(text: &str, failed: bool) -> String {
426 if failed {
427 let first = text.lines().find(|line| !line.trim().is_empty()).unwrap_or("It failed.");
428 return first.chars().take(160).collect();
429 }
430 let Ok(value) = serde_json::from_str::<Value>(text) else {
431 return "Done".to_owned();
432 };
433 let count = |n: usize| if n == 1 { "1 result".to_owned() } else { format!("{n} results") };
434 match &value {
435 Value::Array(items) => count(items.len()),
436 Value::Object(fields) => {
437 // The list an answer is about, where it has one.
438 for key in ["events", "items", "results", "pulls", "issues", "runs", "workflow_runs", "jobs", "chunks", "entries", "repositories", "threads"] {
439 if let Some(items) = fields.get(key).and_then(Value::as_array) {
440 return count(items.len());
441 }
442 }
443 match fields.get("title").and_then(Value::as_str) {
444 Some(title) => title.chars().take(120).collect(),
445 None => "Done".to_owned(),
446 }
447 }
448 _ => "Done".to_owned(),
449 }
450}
451
452// --- What the model is given --------------------------------------------------
453
454/// Keys that cost the model tokens and tell it nothing.
455const NOISE: &[&str] = &["avatar_url", "gravatar_id", "node_id", "avatar", "etag"];
456
457/// Which end of a long result matters: the start of a list (newest first),
458/// or the end of a log, where a failed job says why.
459#[derive(Clone, Copy, Debug, PartialEq, Eq)]
460pub enum Keep {
461 Start,
462 End,
463}
464
465impl Keep {
466 /// The end a call's result is read from.
467 pub fn of(tool: &str, arguments: &Value) -> Keep {
468 if tool == "workflow" && arguments["action"].as_str().is_some_and(|action| action.contains("log")) {
469 Keep::End
470 } else {
471 Keep::Start
472 }
473 }
474}
475
476/// A result as the model is given it: compact JSON, at most `max`
477/// characters. Long texts and long lists are trimmed first, each saying
478/// how much was left out, so what is kept is still whole and readable; only
479/// then is the text cut, with a note saying how to read the rest. `keep`
480/// says which end of a list or text to keep.
481pub fn trim_result(text: &str, max: usize, keep: Keep) -> String {
482 let max = max.max(200);
483 let Ok(value) = serde_json::from_str::<Value>(text) else {
484 return cut(text, max, keep);
485 };
486 let value = strip(value);
487 let compact = value.to_string();
488 if compact.chars().count() <= max {
489 return compact;
490 }
491 // Trim texts and lists, harder each time, until it fits.
492 for (chars, items) in [(4_000, 100), (1_500, 50), (600, 25), (300, 12), (160, 6), (100, 3)] {
493 let trimmed = shorten(&value, chars, items, keep).to_string();
494 if trimmed.chars().count() <= max {
495 return trimmed;
496 }
497 }
498 cut(&shorten(&value, 100, 3, keep).to_string(), max, keep)
499}
500
501fn cut(text: &str, max: usize, keep: Keep) -> String {
502 let total = text.chars().count();
503 if total <= max {
504 return text.to_owned();
505 }
506 let room = max.saturating_sub(120);
507 let left = total - room;
508 match keep {
509 Keep::Start => {
510 let kept: String = text.chars().take(room).collect();
511 format!("{kept}\n[Cut: {left} more characters not shown. Ask for less: a narrower filter, a shorter span or one item.]")
512 }
513 Keep::End => {
514 let kept: String = text.chars().skip(left).collect();
515 format!("[Cut: the first {left} characters not shown.]\n{kept}")
516 }
517 }
518}
519
520fn strip(value: Value) -> Value {
521 match value {
522 Value::Object(fields) => Value::Object(
523 fields
524 .into_iter()
525 .filter(|(key, value)| !NOISE.contains(&key.as_str()) && !value.as_str().is_some_and(|text| text.contains("{?")))
526 .map(|(key, value)| (key, strip(value)))
527 .collect(),
528 ),
529 Value::Array(items) => Value::Array(items.into_iter().map(strip).collect()),
530 other => other,
531 }
532}
533
534fn shorten(value: &Value, chars: usize, items: usize, keep: Keep) -> Value {
535 match value {
536 Value::String(text) if text.chars().count() > chars => {
537 let total = text.chars().count();
538 match keep {
539 Keep::Start => {
540 let kept: String = text.chars().take(chars).collect();
541 json!(format!("{kept}… [{} more characters]", total - chars))
542 }
543 Keep::End => {
544 let kept: String = text.chars().skip(total - chars).collect();
545 json!(format!("[{} characters before] …{kept}", total - chars))
546 }
547 }
548 }
549 Value::Array(list) if list.len() > items => {
550 let note = json!(format!("[{} more not shown: ask for fewer, or another page]", list.len() - items));
551 let shortened = |item: &Value| shorten(item, chars, items, keep);
552 match keep {
553 Keep::Start => {
554 let mut kept: Vec<Value> = list.iter().take(items).map(shortened).collect();
555 kept.push(note);
556 Value::Array(kept)
557 }
558 Keep::End => {
559 let mut kept = vec![note];
560 kept.extend(list.iter().skip(list.len() - items).map(shortened));
561 Value::Array(kept)
562 }
563 }
564 }
565 Value::Array(list) => Value::Array(list.iter().map(|item| shorten(item, chars, items, keep)).collect()),
566 Value::Object(fields) => Value::Object(
567 fields.iter().map(|(key, value)| (key.clone(), shorten(value, chars, items, keep))).collect::<Map<_, _>>(),
568 ),
569 other => other.clone(),
570 }
571}
572
573// --- Sources -------------------------------------------------------------------
574
575/// The pages on g1t an answer read, kept in the order first read.
576#[derive(Default)]
577pub struct Sources {
578 found: Vec<(String, String, bool)>,
579 seen: HashSet<String>,
580}
581
582/// The most pages kept from what one answer read.
583const MOST_READ: usize = 400;
584
585impl Sources {
586 fn add(&mut self, url: String, title: String, direct: bool) {
587 if self.found.len() >= MOST_READ {
588 return;
589 }
590 let url = url.trim_end_matches('/').to_owned();
591 if self.seen.insert(url.clone()) {
592 self.found.push((url, title, direct));
593 } else if direct && let Some(found) = self.found.iter_mut().find(|found| found.0 == url) {
594 found.2 = true;
595 }
596 }
597
598 /// Notes the pages a call's result names: each item's own `url` or
599 /// `html_url` on the site, a repository, and the issues and pull
600 /// requests of the repository it was about. `direct` for a call about
601 /// one thing (its link), which is a source whether cited or not.
602 pub fn read(&mut self, site: &str, tool: &str, arguments: &Value, text: &str, direct: Option<(&str, &str)>) {
603 let site = site.trim_end_matches('/');
604 if let Some((url, title)) = direct {
605 self.add(url.to_owned(), title.to_owned(), true);
606 }
607 let Ok(value) = serde_json::from_str::<Value>(text) else {
608 return;
609 };
610 let repo = arguments["repo"].as_str().filter(|repo| repo.contains('/')).map(|repo| repo.trim_matches('/').to_owned());
611 self.walk(site, tool, repo.as_deref(), &value, 0);
612 }
613
614 fn walk(&mut self, site: &str, tool: &str, repo: Option<&str>, value: &Value, depth: usize) {
615 if depth > 6 {
616 return;
617 }
618 match value {
619 Value::Array(items) => items.iter().for_each(|item| self.walk(site, tool, repo, item, depth + 1)),
620 Value::Object(fields) => {
621 let text = |key: &str| fields.get(key).and_then(Value::as_str).filter(|text| !text.is_empty());
622 let number = fields.get("number").and_then(Value::as_u64);
623 let title = text("title").or_else(|| text("name")).or_else(|| text("full_name")).or_else(|| text("tag"));
624 let own = text("html_url").or_else(|| text("url")).filter(|url| url.starts_with(site));
625 if let Some(url) = own {
626 let title = match (title, number) {
627 (Some(title), Some(number)) => format!("#{number} {title}"),
628 (Some(title), None) => title.to_owned(),
629 (None, _) => url.trim_start_matches(site).trim_start_matches('/').to_owned(),
630 };
631 self.add(url.to_owned(), title, false);
632 } else if let (Some(repo), Some(number), Some(title)) = (repo, number, text("title")) {
633 let section = if tool == "pull_request" || fields.contains_key("merged_at") { "pull" } else { "issues" };
634 self.add(format!("{site}/{repo}/{section}/{number}"), format!("#{number} {title}"), false);
635 } else if let (Some(namespace), Some(name)) = (text("namespace"), text("name"))
636 && fields.contains_key("default_branch")
637 {
638 self.add(format!("{site}/{namespace}/{name}"), format!("{namespace}/{name}"), false);
639 }
640 for (key, value) in fields {
641 if matches!(value, Value::Array(_) | Value::Object(_)) && key != "author" && key != "user" {
642 self.walk(site, tool, repo, value, depth + 1);
643 }
644 }
645 }
646 _ => {}
647 }
648 }
649
650 /// What the answer drew on: the pages it links to, in the order read,
651 /// then those it read directly, at most [`ASK_MAX_SOURCES`].
652 pub fn finish(&self, site: &str, answer: &str) -> Vec<AskSource> {
653 let site = site.trim_end_matches('/');
654 let cites = |url: &str| {
655 let path = url.trim_start_matches(site);
656 answer.contains(url) || (!path.is_empty() && path != "/" && contains_path(answer, path))
657 };
658 let mut sources: Vec<AskSource> = self
659 .found
660 .iter()
661 .filter(|(url, _, _)| cites(url))
662 .map(|(url, title, _)| AskSource { title: title.clone(), url: url.clone(), cited: true })
663 .collect();
664 for (url, title, direct) in &self.found {
665 if *direct && !sources.iter().any(|source| &source.url == url) {
666 sources.push(AskSource { title: title.clone(), url: url.clone(), cited: false });
667 }
668 }
669 sources.truncate(ASK_MAX_SOURCES);
670 sources
671 }
672}
673
674/// Whether `answer` names `path` as a whole path, not the start of a
675/// longer one (`/acme/web/pull/7` is not cited by `/acme/web/pull/70`).
676fn contains_path(answer: &str, path: &str) -> bool {
677 answer.match_indices(path).any(|(at, _)| {
678 let next = answer[at + path.len()..].chars().next();
679 !next.is_some_and(|c| c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_')
680 })
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 fn listing() -> Value {
688 json!({ "tools": [
689 {
690 "name": "pull_request",
691 "description": "Pull requests",
692 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "changes"] } }, "required": ["action"] },
693 "annotations": { "readOnlyHint": true },
694 },
695 {
696 "name": "search",
697 "description": "Find things",
698 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["code", "ticket"], "default": "code" } } },
699 "annotations": { "readOnlyHint": true },
700 },
701 {
702 "name": "issue",
703 "description": "Issues",
704 "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "create"] } } },
705 "annotations": { "readOnlyHint": false },
706 },
707 ] })
708 }
709
710 #[test]
711 fn the_servers_tools_are_read_as_they_are_listed() {
712 let tools = listed(&listing());
713 assert_eq!(tools.len(), 3);
714 assert_eq!(tools[0].spec.name, "pull_request");
715 assert_eq!(tools[0].spec.input_schema["required"], json!(["action"]));
716 assert_eq!(tools[0].actions, vec!["list", "get", "changes"]);
717 assert_eq!(tools[1].default_action.as_deref(), Some("code"));
718 assert!(!tools[2].read_only);
719 assert!(listed(&json!({})).is_empty());
720 }
721
722 #[test]
723 fn ask_runs_reads_and_refuses_everything_else() {
724 let toolbox = Toolbox::new(listed(&listing()), native());
725 // A tool that is not read-only is not offered at all.
726 let names: Vec<&str> = toolbox.specs().iter().map(|spec| spec.name.as_str()).collect();
727 assert_eq!(names, vec!["pull_request", "search", PROPOSE_SESSION]);
728 assert_eq!(toolbox.decide("pull_request", &json!({ "action": "get", "repo": "a/b", "number": 1 })), Decision::Run);
729 assert_eq!(toolbox.decide("search", &json!({ "query": "x" })), Decision::Run, "the default action");
730 // An action the server did not list for this credential.
731 let Decision::Refuse(why) = toolbox.decide("pull_request", &json!({ "action": "merge" })) else { panic!() };
732 assert!(why.contains("changes nothing itself"), "{why}");
733 assert!(matches!(toolbox.decide("pull_request", &json!({})), Decision::Refuse(_)));
734 assert!(matches!(toolbox.decide("issue", &json!({ "action": "create" })), Decision::Refuse(_)));
735 assert!(matches!(toolbox.decide("nope", &json!({})), Decision::Refuse(_)));
736 }
737
738 #[test]
739 fn a_dispatch_waits_for_the_persons_yes() {
740 let start = Native {
741 spec: ToolSpec { name: "start_session".into(), description: "Start a session".into(), input_schema: json!({ "type": "object" }) },
742 kind: Kind::Dispatch,
743 };
744 let toolbox = Toolbox::new(Vec::new(), vec![start]);
745 assert!(toolbox.is_native("start_session"));
746 assert_eq!(toolbox.decide("start_session", &json!({ "repo": "a/b", "number": 3 })), Decision::Confirm);
747 }
748
749 #[test]
750 fn a_proposed_session_is_a_card_the_person_starts() {
751 let toolbox = Toolbox::new(Vec::new(), native());
752 assert!(toolbox.is_native(PROPOSE_SESSION));
753 let on_issue = json!({ "repo": "acme/web", "issue": 12, "mode": "implement", "title": "ignored", "instruction": " use the new name " });
754 assert_eq!(toolbox.decide(PROPOSE_SESSION, &on_issue), Decision::Confirm);
755 let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction, new_repository, deploy, .. } =
756 proposal("proposal_0_0", &on_issue).unwrap()
757 else {
758 panic!()
759 };
760 assert_eq!((id.as_str(), repo.as_str(), issue, mode.as_str()), ("proposal_0_0", "acme/web", Some(12), "implement"));
761 assert_eq!((title, body, instruction.as_deref()), (None, None, Some("use the new name")));
762 assert!(!new_repository && !deploy, "an existing repository is left as it is");
763 let new_issue = json!({ "repo": "acme/web", "title": "Add a collaborators route", "body": "List them.", "mode": "plan" });
764 let AskEvent::Proposal { issue, title, .. } = proposal("p", &new_issue).unwrap() else { panic!() };
765 assert_eq!((issue, title.as_deref()), (None, Some("Add a collaborators route")));
766 assert!(proposal("p", &json!({ "repo": "acme/web", "mode": "plan" })).is_err(), "no issue and no title");
767 assert!(proposal("p", &json!({ "repo": "acme", "issue": 1, "mode": "plan" })).is_err(), "not owner/name");
768 assert!(proposal("p", &json!({ "repo": "acme/web", "issue": 1, "mode": "ship" })).is_err(), "no such mode");
769 assert_eq!(label(PROPOSE_SESSION, &on_issue), "Proposed a session on issue #12 in acme/web");
770 }
771
772 #[test]
773 fn a_new_repository_is_created_deployed_and_built_from_one_card() {
774 let build = json!({ "repo": "acme/hello-world", "new_repository": true, "title": "Hello world", "body": "A page that says hello.", "mode": "implement" });
775 let AskEvent::Proposal { repo, issue, title, new_repository, private, deploy, .. } = proposal("p", &build).unwrap() else { panic!() };
776 assert_eq!((repo.as_str(), issue, title.as_deref()), ("acme/hello-world", None, Some("Hello world")));
777 assert!(new_repository && deploy && !private, "public and deployed unless asked otherwise");
778 let quiet = json!({ "repo": "acme/notes", "new_repository": true, "private": true, "deploy": false, "title": "Notes", "mode": "implement" });
779 let AskEvent::Proposal { private, deploy, .. } = proposal("p", &quiet).unwrap() else { panic!() };
780 assert!(private && !deploy);
781 assert!(proposal("p", &json!({ "repo": "acme/hello world", "new_repository": true, "title": "x", "mode": "implement" })).is_err());
782 assert!(proposal("p", &json!({ "repo": "acme/hello", "new_repository": true, "title": "x", "mode": "plan" })).is_err());
783 assert!(proposal("p", &json!({ "repo": "acme/hello", "new_repository": true, "issue": 1, "mode": "implement" })).is_err());
784 assert_eq!(label(PROPOSE_SESSION, &build), "Proposed building acme/hello-world, a new repository");
785 }
786
787 #[test]
788 fn steps_say_what_they_do_in_a_line() {
789 assert_eq!(label("pull_request", &json!({ "action": "list", "repo": "acme/web" })), "Listed pull requests in acme/web");
790 assert_eq!(label("pull_request", &json!({ "action": "get", "repo": "acme/web", "number": 7 })), "Read pull request #7 in acme/web");
791 assert_eq!(
792 label("pull_request", &json!({ "action": "changes", "repo": "acme/web", "number": 7 })),
793 "Read changes of pull request #7 in acme/web"
794 );
795 assert_eq!(label("workflow", &json!({ "action": "list_runs", "repo": "acme/web" })), "Listed runs in acme/web");
796 assert_eq!(label("workflow", &json!({ "action": "job_logs", "repo": "acme/web" })), "Read job logs in acme/web");
797 assert_eq!(
798 label("workspace", &json!({ "action": "activity", "workspace": "acme", "since": "2026-10-12T00:00:00Z" })),
799 "Read activity in acme since 2026-10-12"
800 );
801 assert_eq!(label("search", &json!({ "query": "login bug" })), "Searched g1t for \u{201c}login bug\u{201d}");
802 assert_eq!(label("repository", &json!({ "action": "list" })), "Listed repositories");
803 }
804
805 #[test]
806 fn steps_link_to_the_page_they_read() {
807 let site = "https://g1t.sh/";
808 assert_eq!(link(site, "pull_request", &json!({ "repo": "acme/web", "number": 7 })).unwrap(), "https://g1t.sh/acme/web/pull/7");
809 assert_eq!(link(site, "issue", &json!({ "action": "list", "repo": "acme/web" })).unwrap(), "https://g1t.sh/acme/web/issues");
810 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");
811 assert_eq!(link(site, "workspace", &json!({ "action": "activity", "workspace": "acme" })).unwrap(), "https://g1t.sh/acme");
812 assert_eq!(link(site, "search", &json!({ "query": "x" })), None);
813 }
814
815 #[test]
816 fn steps_say_what_they_found() {
817 assert_eq!(summary("[1,2,3]", false), "3 results");
818 assert_eq!(summary("{\"events\":[{}],\"summary\":{}}", false), "1 result");
819 assert_eq!(summary("{\"title\":\"Fix login\",\"number\":7}", false), "Fix login");
820 assert_eq!(summary("Not allowed: needs pull_requests:write\nmore", true), "Not allowed: needs pull_requests:write");
821 assert_eq!(summary("plain", false), "Done");
822 assert!(is_not_found("Repository not found."));
823 assert!(is_not_found("There is no such project."));
824 assert!(!is_not_found("Not allowed: needs pull_requests:write"));
825 assert!(!is_not_found("g1t answered 500."));
826 }
827
828 #[test]
829 fn big_results_are_trimmed_not_dropped() {
830 let small = json!({ "title": "x", "avatar_url": "https://a", "url_template": "https://x/{?q}" }).to_string();
831 assert_eq!(trim_result(&small, 1_000, Keep::Start), "{\"title\":\"x\"}");
832 // A long list keeps its first items, whole, and says how many more.
833 let items: Vec<Value> = (0..400).map(|n| json!({ "number": n, "title": format!("Pull request {n}"), "body": "b".repeat(3_000) })).collect();
834 let big = json!({ "items": items }).to_string();
835 let kept = trim_result(&big, 16_000, Keep::Start);
836 assert!(kept.chars().count() <= 16_000, "{}", kept.len());
837 let read: Value = serde_json::from_str(&kept).expect("still JSON");
838 let list = read["items"].as_array().unwrap();
839 assert_eq!(list[0]["number"], 0);
840 assert!(list.last().unwrap().as_str().unwrap().contains("more not shown"));
841 assert!(list[0]["body"].as_str().unwrap().contains("more characters"));
842 // Text that is not JSON is cut with a note.
843 let log = "line\n".repeat(10_000);
844 let kept = trim_result(&log, 1_000, Keep::Start);
845 assert!(kept.chars().count() <= 1_000);
846 assert!(kept.contains("[Cut:"));
847 }
848
849 #[test]
850 fn a_logs_end_is_kept_where_a_failure_says_why() {
851 assert_eq!(Keep::of("workflow", &json!({ "action": "job_logs" })), Keep::End);
852 assert_eq!(Keep::of("workflow", &json!({ "action": "list_runs" })), Keep::Start);
853 assert_eq!(Keep::of("pull_request", &json!({ "action": "list" })), Keep::Start);
854 let mut chunks: Vec<Value> = (0..2_000).map(|seq| json!({ "seq": seq, "step": 3, "text": format!("building part {seq}\n") })).collect();
855 chunks.push(json!({ "seq": 2_000, "step": 4, "text": "##[error]No space left on device\n" }));
856 let log = json!({ "chunks": chunks, "done": true }).to_string();
857 let kept = trim_result(&log, 4_000, Keep::End);
858 assert!(kept.chars().count() <= 4_000);
859 let read: Value = serde_json::from_str(&kept).expect("still JSON");
860 let kept_chunks = read["chunks"].as_array().unwrap();
861 assert!(kept_chunks[0].as_str().unwrap().contains("more not shown"));
862 assert!(kept_chunks.last().unwrap()["text"].as_str().unwrap().contains("No space left on device"));
863 // Plain text keeps its end too.
864 let text = format!("{}error: disk full", "noise\n".repeat(1_000));
865 let kept = trim_result(&text, 500, Keep::End);
866 assert!(kept.ends_with("error: disk full"));
867 assert!(kept.starts_with("[Cut: the first"));
868 }
869
870 #[test]
871 fn sources_are_the_pages_the_answer_cites_then_those_it_read() {
872 let site = "https://g1t.sh";
873 let mut sources = Sources::default();
874 let activity = json!({ "events": [
875 { "kind": "pull_merged", "repo": "acme/web", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7" },
876 { "kind": "release_published", "repo": "acme/api", "tag": "v1.2.0", "title": "Faster", "url": "https://g1t.sh/acme/api/releases/tag/v1.2.0" },
877 { "kind": "pull_merged", "repo": "acme/web", "number": 70, "title": "Other", "url": "https://g1t.sh/acme/web/pull/70" },
878 ] });
879 sources.read(site, "workspace", &json!({ "action": "activity", "workspace": "acme" }), &activity.to_string(), None);
880 let list = json!([{ "number": 9, "title": "Docs", "merged_at": null }]);
881 sources.read(site, "pull_request", &json!({ "action": "list", "repo": "acme/docs" }), &list.to_string(), None);
882 sources.read(
883 site,
884 "issue",
885 &json!({ "action": "get", "repo": "acme/web", "number": 3 }),
886 "{\"issue\":{\"title\":\"Login fails\"}}",
887 Some(("https://g1t.sh/acme/web/issues/3", "Read issue #3 in acme/web")),
888 );
889 let answer = "Shipped: [Fix login](https://g1t.sh/acme/web/pull/7) and acme/docs [#9](/acme/docs/pull/9).";
890 let found = sources.finish(site, answer);
891 assert_eq!(
892 found,
893 vec![
894 AskSource { title: "#7 Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true },
895 AskSource { title: "#9 Docs".into(), url: "https://g1t.sh/acme/docs/pull/9".into(), cited: true },
896 AskSource { title: "Read issue #3 in acme/web".into(), url: "https://g1t.sh/acme/web/issues/3".into(), cited: false },
897 ]
898 );
899 }
900}