Skip to content

Commit fea11db

Browse files
Keep the 1.x defaults that existing agents rely on
- model.constraints top_p, top_k and stop_sequences reach every provider whose API takes them; the others are reported by the run and by validate, which also warns that current Claude models reject sampling settings. The SDKs' constraints apply them too. - TypeScript tool handlers get an AbortSignal again, aborted when the turn is stopped or the session ends. - A plugin's settings reach its script tools and hooks as environment variables. - compliance/regulatory-map.yaml and validation-schedule.yaml are summed up in the system prompt again. - .sh tools and hooks run under sh unless their #! line names another shell; hooks get 10 seconds by default. - Unknown gitagent.yaml keys are warnings instead of errors, and validate checks the file. - README: what changed from 1.x; BLUEPRINT D28.
1 parent 3e939ac commit fea11db

31 files changed

Lines changed: 696 additions & 38 deletions

File tree

‎BLUEPRINT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Steps 4 to 9 repeat until the stop check accepts, the agent replies in conversat
131131
| D25 | Sub-agents are warned as their turns run low, may be given their own turn limit (up to 100), and come back marked partial when they run out | A review cut off mid-step wastes its whole cost; a warned one hands back what it found, and the lead re-runs only what is marked partial |
132132
| D26 | The `/tests` write guard applies to benchmark runs only | It protects graders' tests; in a user's project `/tests` is an ordinary path and a pattern such as `grep -v /tests/` must not be refused |
133133
| D27 | One throttle per session: `max_parallel` is a ceiling, a rate-limit refusal halves the window, ten clean calls in a row give a slot back | The right number of parallel calls depends on the account's rate limit, which gitagent cannot know; backing off beats retrying blindly, and the ceiling stays the user's choice |
134+
| D28 | Where a 1.x agent relies on a default, 1.x's default stays: `.sh` scripts under `sh`, 10-second hooks, every sampling setting a provider takes, plugin settings for plugin scripts, unknown `gitagent.yaml` keys as warnings; a setting that cannot be honoured is a warning, never silent | Existing agents must run unchanged, and a difference nobody is told about costs more than an old default |
134135

135136
**Left out on purpose:** critics that rewrite answers; best-of-N on benchmarks; memory or learning during a scored run; automated rewriting of the harness; masking every turn; third-party multi-provider frameworks; a web interface; voice; remote sandboxes.
136137

‎README.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,13 +533,24 @@ gitagent 3 replaces the TypeScript packages (1.x and 2.x) with one binary. What
533533
- **Agent folders.** `agent.yaml`, `SOUL.md`, `RULES.md`, `memory/`, `skills/`, `tools/`, `hooks/`, `schedules/`, `workflows/`, `agents/`, `config/` and `.env` are read as they were. 1.x settings such as `tools: [cli, read, write, memory]`, `runtime.timeout` and the 1.x `compliance` shape are accepted; `gitagent validate` points out the newer spelling as a warning, never an error.
534534
- **Hook scripts.** They get the same JSON and answer the same way; output that is not an answer, or a script that crashes, allows the action with a warning, as before. Only hooks marked `compliance: true` or `fail_open: false`, which are new, block in those cases.
535535
- **The CLI.** `gitagent`, `-p`, `-d`, `-m`, `-e`, `--repo`, `--pat`, `--session` and the REPL's slash commands work as before.
536-
- **SDK code.** `query()`, `tool()`, hooks, `steer()`, `abort()`, the message types and the `repo` option are the same; the TypeScript package now needs the `gitagent` binary installed.
536+
- **SDK code.** `query()`, `tool()`, hooks, `steer()`, `abort()`, the message types and the `repo` option are the same; the TypeScript package now needs the `gitagent` binary installed. A `tool()` handler still gets the arguments and an `AbortSignal`, and may return a string, 1.x's `{text, details}` or `{content, isError}`.
537+
- **Model settings.** `model.constraints` (`temperature`, `max_tokens`, `top_p`, `top_k`, `stop_sequences`) are sent to the providers whose API takes them. OpenAI's API has no `top_k` or stop sequences, so those two are ignored there with a warning. Current Claude models reject `temperature`, `top_p` and `top_k`, so the model call fails; `gitagent validate` warns about them.
538+
- **Scripts.** A declared tool or hook ending in `.sh` runs under `sh`, as before, unless its `#!` line names another shell. Hooks get 10 seconds unless `hooks.yaml` sets `timeout`. A plugin's settings reach its script tools and hooks as environment variables (see `plugin.yaml`'s `env` names, or `GITAGENT_PLUGIN_CONFIG` for all of them as JSON).
539+
- **Compliance files.** The frameworks named in `compliance/regulatory-map.yaml` and the checks in `compliance/validation-schedule.yaml` are added to the system prompt, as before.
537540

538-
What changed: the binary must be installed (one `curl` line, above); `--voice` and the remote `--sandbox` are not part of gitagent 3; a plugin's `entry` JavaScript is not run (plugins contribute tools, skills, hooks and prompt text through files); usage errors exit with 2, provider failures with 3 and interrupts with 130 instead of 1.
541+
### What changed
542+
543+
- **Install.** The binary must be installed (one `curl` line, above). The TypeScript and Python packages drive it.
544+
- **Exit codes.** 1.x exited with 1 for every error. gitagent 3 exits with 0 when a run ends (also when it runs out of turns or time), 2 for usage and setup errors, 3 when the model provider fails, and 130 when the run is stopped. A script that only checks for a nonzero code works as before; one that checks for exactly 1 needs updating.
545+
- **Not carried over.** `--voice`; the remote `--sandbox` (the binary prints a one-line note); a plugin's `entry` JavaScript (plugins contribute tools, skills, hooks and prompt text through files); the 1.x telemetry adapter (OTLP export replaces it).
546+
- **Task list.** 1.x's `task_tracker` kept a searchable list of tasks. gitagent 3's `todo` is a plain checklist the agent rewrites whole; the name `task_tracker` still maps to it in tool lists and hooks.
547+
- **Learning.** 1.x's online skill learning, which adjusted a skill's confidence after each use, is gone. Lessons are learned from finished runs instead (`learning: online` or `gitagent learn`, below) and go into the system prompt; skills are not changed.
548+
- **Sandbox on macOS.** The sandbox is Linux-only (Landlock and seccomp). On macOS the agent's shell runs without one, where 1.x's remote sandbox worked on every system.
549+
- **Scripts without a runtime.** 1.x ran every declared tool with `sh` unless `runtime` was set. gitagent 3 picks the interpreter by extension (`.py` with `python3`, `.js` with `node`, `.rb` with `ruby`, …), so such tools now run instead of failing.
539550

540551
## Configuration
541552

542-
gitagent reads `gitagent.yaml` from `--config`, else the project folder (`gitagent.yaml` or `.gitagent/gitagent.yaml`), else `~/.gitagent/gitagent.yaml`. The other files are looked up one by one in the same places (the config's folder, the project's `.gitagent/`, then `~/.gitagent/`, then the project's and home `.claude/`), and fall back to the built-in versions:
553+
gitagent reads `gitagent.yaml` from `--config`, else the project folder (`gitagent.yaml` or `.gitagent/gitagent.yaml`), else `~/.gitagent/gitagent.yaml`. The other files are looked up one by one in the same places (the config's folder, the project's `.gitagent/`, then `~/.gitagent/`, then the project's and home `.claude/`), and fall back to the built-in versions. A key that `gitagent.yaml` does not know is ignored with a warning, and `gitagent validate` checks the project's copy.
543554

544555
| Path | What it is |
545556
|---|---|

‎clients/typescript/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ console.log(q.result()?.outcome, q.costs());
4646
the session after the current turn.
4747
- `sessionId()`, `manifest()`, `messages()`, `result()` and `costs()`
4848
report on the session.
49+
- A tool's handler gets the arguments and an `AbortSignal`, aborted when
50+
the turn is stopped or the session ends. It returns a string, `{content,
51+
isError?}`, or 1.x's `{text, details?}` (the text becomes the result;
52+
`details` is not sent to the model).
4953
- Hooks (`onSessionStart`, `preQuery`, `preToolUse`, `postToolUse`,
5054
`postToolFailure`, `fileChanged`,
5155
`postResponse`, `onError`) get the protocol's input and return `{action:

‎clients/typescript/src/index.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export interface GCToolDefinition {
6666
name: string;
6767
description: string;
6868
inputSchema: JsonObject;
69-
handler: (args: JsonObject) => unknown | Promise<unknown>;
69+
/**
70+
* Returns a string, `{ content, isError? }`, or 1.x's `{ text, details? }`.
71+
* `signal` is aborted when the running turn is stopped or the session ends.
72+
*/
73+
handler: (args: JsonObject, signal: AbortSignal) => unknown | Promise<unknown>;
7074
}
7175

7276
/** 1.x's local repo mode: the session works in a clone of `url` on a
@@ -97,8 +101,14 @@ export interface QueryOptions {
97101
sessionId?: string;
98102
resume?: boolean;
99103
mcpServers?: Record<string, JsonObject>;
100-
/** `topP` and `topK` are accepted for 1.x code and ignored with a warning. */
101-
constraints?: { temperature?: number; maxTokens?: number; topP?: number; topK?: number };
104+
/** A setting the model's provider has no field for is ignored with a warning. */
105+
constraints?: {
106+
temperature?: number;
107+
maxTokens?: number;
108+
topP?: number;
109+
topK?: number;
110+
stopSequences?: string[];
111+
};
102112
tools?: GCToolDefinition[];
103113
hooks?: GCHooks;
104114
/** Hooks whose failure blocks the action; the others report the failure
@@ -194,6 +204,8 @@ export class Query implements AsyncIterable<GCMessage> {
194204
private readonly wakers: ((m: GCMessage | null) => void)[] = [];
195205
private readonly stderrLines: string[] = [];
196206
private readonly pending: string[] = [];
207+
/** Given to client tools; aborted, and replaced, when a turn is stopped. */
208+
private toolAbort = new AbortController();
197209
private promptsDone = false;
198210
private openTurn = false;
199211
/** A turn ended with nothing queued: the session ends when the caller
@@ -321,11 +333,13 @@ export class Query implements AsyncIterable<GCMessage> {
321333
/** Stops the running turn; the session stays open. */
322334
abort(): void {
323335
this.write({ type: "abort" });
336+
this.stopTools();
324337
}
325338

326339
/** Stops the running turn and ends the session. */
327340
kill(): void {
328341
this.write({ type: "kill" });
342+
this.stopTools();
329343
}
330344

331345
/** Ends the session after the current turn. */
@@ -357,9 +371,15 @@ export class Query implements AsyncIterable<GCMessage> {
357371
else this.waiting.push(message);
358372
}
359373

374+
private stopTools(): void {
375+
this.toolAbort.abort();
376+
this.toolAbort = new AbortController();
377+
}
378+
360379
private finish(): void {
361380
if (this.closed) return;
362381
this.closed = true;
382+
this.toolAbort.abort();
363383
this.readyResolve(this.ready !== undefined);
364384
for (const w of this.wakers.splice(0)) w(null);
365385
}
@@ -431,7 +451,7 @@ export class Query implements AsyncIterable<GCMessage> {
431451
if (kind === "tool") {
432452
const t = this.tools.get(name);
433453
if (!t) return { content: `no handler for the tool ${name}`, isError: true };
434-
const result = await t.handler(input);
454+
const result = await t.handler(input, this.toolAbort.signal);
435455
if (result === undefined || result === null) return { content: "" };
436456
if (typeof result === "object") return result as JsonObject;
437457
return { content: String(result) };

‎crates/cli/src/ipc.rs‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -717,14 +717,31 @@ async fn session(cli: Cli, out: Out) -> i32 {
717717
if let Some(n) = c["maxTokens"].as_u64().or(c["max_tokens"].as_u64()) {
718718
s.spec.profile.max_output_tokens = n as u32;
719719
}
720-
for key in ["topP", "topK"] {
721-
if !c[key].is_null() {
722-
out.system(
723-
"warning",
724-
format!("constraints.{key} is not supported; ignored"),
725-
None,
726-
);
727-
}
720+
let sampling = &mut s.spec.profile.sampling;
721+
if let Some(p) = c["topP"].as_f64().or(c["top_p"].as_f64()) {
722+
sampling.top_p = Some(p as f32);
723+
}
724+
if let Some(k) = c["topK"].as_u64().or(c["top_k"].as_u64()) {
725+
sampling.top_k = Some(k as u32);
726+
}
727+
let stops = c.get("stopSequences").or(c.get("stop_sequences"));
728+
if let Some(list) = stops.and_then(Value::as_array) {
729+
sampling.stop_sequences = list
730+
.iter()
731+
.filter_map(Value::as_str)
732+
.map(str::to_string)
733+
.collect();
734+
}
735+
let family = s.spec.model.family();
736+
for name in s.spec.profile.sampling.unsupported(family) {
737+
out.system(
738+
"warning",
739+
format!(
740+
"constraints.{name} is not supported by {} models; ignored",
741+
family.as_str()
742+
),
743+
None,
744+
);
728745
}
729746
for (name, v) in o["mcpServers"].as_object().into_iter().flatten() {
730747
match gitagent_loader::mcp::parse(name, v, &s.work_dir) {

‎crates/core/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ pub async fn run(
505505
tools: tools.clone(),
506506
effort,
507507
temperature: spec.profile.temperature,
508+
sampling: spec.profile.sampling.clone(),
508509
max_output_tokens: spec.profile.max_output_tokens,
509510
cache: cache_plan,
510511
server_masking,
@@ -2045,6 +2046,7 @@ impl Compaction<'_> {
20452046
tools: self.tools.to_vec(),
20462047
effort: self.spec.profile.effort,
20472048
temperature: self.spec.profile.temperature,
2049+
sampling: self.spec.profile.sampling.clone(),
20482050
max_output_tokens: self.spec.profile.max_output_tokens,
20492051
cache: self.cache,
20502052
server_masking: None,

‎crates/core/src/tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,7 @@ async fn declared_tools_marked_for_confirmation_always_ask() {
14521452
timeout: Duration::from_secs(10),
14531453
requires_confirmation: true,
14541454
read_only: true,
1455+
env: Default::default(),
14551456
});
14561457
let script = "- tool_calls: [{name: pay, arguments: {note: nope}}]\n\
14571458
- tool_calls: [{name: pay, arguments: {note: approved}}]\n\
@@ -2178,6 +2179,7 @@ async fn a_workflow_tool_step_needs_the_same_approval_as_the_model() {
21782179
timeout: Duration::from_secs(10),
21792180
requires_confirmation: true,
21802181
read_only: false,
2182+
env: Default::default(),
21812183
});
21822184
let provider = ScriptedProvider::from_yaml("- text: should not be asked\n").unwrap();
21832185
let report = run_workflow(

‎crates/hooks/src/lifecycle.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ impl AgentHooks {
209209
let mut cmd = Command::new(program);
210210
cmd.args(rest)
211211
.envs(self.env.iter().map(|(k, v)| (k, v)))
212+
.envs(&hook.env)
212213
.current_dir(&self.root)
213214
.env("GITAGENT_AGENT_DIR", &self.root)
214215
.env("GITAGENT_SESSION_ID", session_id)
@@ -294,6 +295,7 @@ mod tests {
294295
compliance: false,
295296
fail_open,
296297
applies_to: Vec::new(),
298+
env: Default::default(),
297299
}
298300
}
299301

‎crates/learn/src/distill.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ pub async fn propose(
6262
tools: Vec::new(),
6363
effort: spec.profile.effort,
6464
temperature: spec.profile.temperature,
65+
sampling: spec.profile.sampling.clone(),
6566
max_output_tokens: spec.profile.max_output_tokens,
6667
cache: CachePlan::default(),
6768
server_masking: None,

‎crates/loader/src/agent_hooks.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
//! Script paths are relative to `hooks/` and must stay inside the agent
1919
//! directory. Problems are warnings and the hook is left out.
2020
21+
use std::collections::BTreeMap;
2122
use std::path::Path;
2223
use std::time::Duration;
2324

2425
use serde_json::Value;
2526

2627
use crate::AgentDir;
2728

28-
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
29+
/// How long a hook may run when hooks.yaml gives no `timeout` (1.x's limit).
30+
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
2931

3032
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3133
pub enum HookEvent {
@@ -110,6 +112,8 @@ pub struct HookEntry {
110112
pub fail_open: bool,
111113
/// gitagent tool names this hook is for; empty means every tool.
112114
pub applies_to: Vec<String>,
115+
/// More environment for its script: a plugin's settings.
116+
pub env: BTreeMap<String, String>,
113117
}
114118

115119
/// The hooks in an agent directory's `hooks/hooks.yaml`, in file order, and
@@ -188,6 +192,7 @@ pub fn entry_in(
188192
// As in 1.x, a failing plugin hook is a warning, not a block.
189193
fail_open: true,
190194
applies_to: Vec::new(),
195+
env: BTreeMap::new(),
191196
})
192197
}
193198

@@ -251,6 +256,7 @@ fn entry(event: HookEvent, v: &Value, root: &Path) -> Result<HookEntry, String>
251256
.and_then(Value::as_bool)
252257
.unwrap_or(!flag("compliance")),
253258
applies_to,
259+
env: BTreeMap::new(),
254260
})
255261
}
256262

@@ -290,7 +296,7 @@ mod tests {
290296
assert_eq!(a.timeout, Duration::from_secs(5));
291297
assert!(a.fail_open && !a.compliance);
292298
assert_eq!(a.applies_to, vec!["exec", "write_stdin", "write_file"]);
293-
assert_eq!(a.command[0], "bash");
299+
assert_eq!(a.command[0], "sh");
294300
let b = &hooks[1];
295301
assert_eq!(b.command[0], "python3");
296302
assert_eq!(b.timeout, DEFAULT_TIMEOUT);

0 commit comments

Comments
 (0)