| 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 | |
| 28 | use std::collections::{BTreeMap, HashSet}; |
| 29 | |
| 30 | use g1t_contracts::integrations::{ASK_MAX_SOURCES, AskEvent, AskSource}; |
| 31 | use serde_json::{Map, Value, json}; |
| 32 | use worker::Method; |
| 33 | |
| 34 | use crate::ask::ToolSpec; |
| 35 | use crate::http; |
| 36 | |
| 37 | /// The MCP protocol version Ask speaks. |
| 38 | const PROTOCOL: &str = "2025-06-18"; |
| 39 | |
| 40 | /// g1t's MCP server, as the person asking. |
| 41 | #[derive(Clone)] |
| 42 | pub 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)] |
| 49 | pub 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 | |
| 59 | impl 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. |
| 108 | pub 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)] |
| 133 | pub 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)] |
| 146 | pub 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)] |
| 155 | enum Origin { |
| 156 | Mcp { actions: Vec<String>, default_action: Option<String> }, |
| 157 | Native, |
| 158 | } |
| 159 | |
| 160 | #[derive(Clone, Debug)] |
| 161 | struct Entry { |
| 162 | origin: Origin, |
| 163 | kind: Kind, |
| 164 | } |
| 165 | |
| 166 | /// The tools one answer may use. |
| 167 | #[derive(Clone, Debug, Default)] |
| 168 | pub 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. |
| 174 | pub 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. |
| 182 | pub 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. |
| 187 | pub 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. |
| 211 | pub 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. |
| 237 | pub async fn run_native(name: &str, _arguments: &Value) -> Result<(String, bool), String> { |
| 238 | Err(format!("{name} is not something Ask can do.")) |
| 239 | } |
| 240 | |
| 241 | impl 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. |
| 310 | fn 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 | |
| 315 | fn 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". |
| 335 | pub 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. |
| 376 | pub 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. |
| 399 | pub 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. |
| 429 | const 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)] |
| 434 | pub enum Keep { |
| 435 | Start, |
| 436 | End, |
| 437 | } |
| 438 | |
| 439 | impl 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. |
| 455 | pub 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 | |
| 475 | fn 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 | |
| 494 | fn 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 | |
| 508 | fn 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)] |
| 551 | pub struct Sources { |
| 552 | found: Vec<(String, String, bool)>, |
| 553 | seen: HashSet<String>, |
| 554 | } |
| 555 | |
| 556 | /// The most pages kept from what one answer read. |
| 557 | const MOST_READ: usize = 400; |
| 558 | |
| 559 | impl 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`). |
| 650 | fn 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)] |
| 658 | mod 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 | } |