Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/chat-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export async function summarizeHistory(agentDir: string, branch: string): Promis
prompt,
dir: agentDir,
maxTurns: 1,
timeoutMs: 30_000,
replaceBuiltinTools: true,
tools: [],
systemPrompt: "You are a concise summarizer. Output only the summary, nothing else.",
Expand Down
2 changes: 2 additions & 0 deletions src/sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface QueryOptions {
hooks?: GCHooks;
maxTurns?: number;
abortController?: AbortController;
/** Maximum duration of each model turn before the query is aborted; this is not a whole-query timeout. */
timeoutMs?: number;
sessionId?: string;
/** MCP servers to connect to. Merged with manifest `mcp_servers` (these win on key collision). */
mcpServers?: Record<string, McpServerConfig>;
Expand Down
67 changes: 60 additions & 7 deletions src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export function query(options: QueryOptions): Query {
const collectedMessages: GCMessage[] = [];
const ac = options.abortController ?? new AbortController();
const costTracker = new CostTracker();
let removeAbortForwarder: (() => void) | undefined;
const abortQuery = () => {
ac.abort();
};

// These are set once the agent is loaded (async init below)
let _sessionId = options.sessionId ?? "";
Expand Down Expand Up @@ -128,6 +132,11 @@ export function query(options: QueryOptions): Query {
// Async initialization + run
const runPromise = (async () => {
try {
if (options.timeoutMs !== undefined &&
(!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error("timeoutMs must be a finite number greater than zero");
}

// Validate mutually exclusive options
if (options.repo && options.sandbox) {
throw new Error("repo and sandbox options are mutually exclusive");
Expand Down Expand Up @@ -315,6 +324,21 @@ export function query(options: QueryOptions): Query {
...modelOptions,
},
});
const forwardAbort = () => agent.abort();
ac.signal.addEventListener("abort", forwardAbort, { once: true });
removeAbortForwarder = () => ac.signal.removeEventListener("abort", forwardAbort);

const promptWithTimeout = async (prompt: string) => {
if (ac.signal.aborted) return;
const timer = options.timeoutMs === undefined
? undefined
: setTimeout(abortQuery, options.timeoutMs);
try {
await otelContext.with(_session.ctx, () => agent.prompt(prompt));
} finally {
if (timer !== undefined) clearTimeout(timer);
}
};

// 9. Subscribe to events and map to GCMessage
agent.subscribe((event: AgentEvent) => {
Expand Down Expand Up @@ -494,10 +518,32 @@ export function query(options: QueryOptions): Query {
}
});

const emitAbortedResponse = () => {
pushMsg({
type: "assistant",
content: "",
model: (loaded.model as any).id ?? "unknown",
provider: (loaded.model as any).provider ?? "unknown",
stopReason: "aborted",
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalTokens: 0,
costUsd: 0,
},
});
};

// 10. Send prompt — run inside the session span's context so that
// gen_ai.chat and gitagent.tool.execute spans become children of
// gitagent.agent.session.
if (typeof options.prompt === "string") {
if (ac.signal.aborted) {
emitAbortedResponse();
return;
}
// Fire pre_query hook before sending to LLM
if (hooksConfig?.hooks.pre_query) {
const result = await runHooks(hooksConfig.hooks.pre_query, loaded.agentDir, {
Expand All @@ -515,12 +561,15 @@ export function query(options: QueryOptions): Query {
return;
}
}
await otelContext.with(_session.ctx, () =>
agent.prompt(options.prompt as string),
);
await promptWithTimeout(options.prompt as string);
if (ac.signal.aborted) return;
} else {
// Multi-turn: iterate the async iterable
for await (const userMsg of options.prompt) {
if (ac.signal.aborted) {
emitAbortedResponse();
return;
}
pushMsg({ type: "user", content: userMsg.content });
// Fire pre_query hook for each turn
if (hooksConfig?.hooks.pre_query) {
Expand All @@ -539,9 +588,8 @@ export function query(options: QueryOptions): Query {
return;
}
}
await otelContext.with(_session.ctx, () =>
agent.prompt(userMsg.content),
);
await promptWithTimeout(userMsg.content);
if (ac.signal.aborted) return;
}
}

Expand All @@ -558,6 +606,11 @@ export function query(options: QueryOptions): Query {
// Ensure channel finishes even if no agent_end event
channel.finish();
} finally {
// Close the channel on every exit path, including abort and hook-block
// returns that happen before the normal completion call.
channel.finish();
removeAbortForwarder?.();
removeAbortForwarder = undefined;
// Tear down MCP servers on every exit path — success, hook-block
// early-return, abort, and error (this finally runs before the
// .catch() handler below). cleanup() is idempotent.
Expand Down Expand Up @@ -604,7 +657,7 @@ export function query(options: QueryOptions): Query {
// Build the Query object (AsyncGenerator + helpers)
const generator: Query = {
abort() {
ac.abort();
abortQuery();
},

steer(_message: string) {
Expand Down
62 changes: 62 additions & 0 deletions test/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

// Dynamic imports since the project is ESM
let query: typeof import("../dist/exports.js").query;
Expand Down Expand Up @@ -204,6 +207,65 @@ describe("wrapToolWithProgrammaticHooks()", () => {
// ── query() error handling ─────────────────────────────────────────────

describe("query()", () => {
it("reports invalid timeout values without loading an agent", async () => {
const messages: any[] = [];
for await (const msg of query({
prompt: "hello",
dir: "/nonexistent/path",
timeoutMs: 0,
})) {
messages.push(msg);
}

const errorMsg = messages.find((m) => m.type === "system" && m.subtype === "error");
assert.match(errorMsg?.content ?? "", /timeoutMs must be a finite number greater than zero/);
});

it("honors an already-aborted controller before starting the model", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "gitagent-abort-"));
try {
writeFileSync(join(agentDir, "agent.yaml"), `spec_version: "1.0"\nname: test\nversion: "1.0.0"\ndescription: test\nmodel:\n preferred: openai:gpt-4o-mini\n fallback: []\ntools: []\nruntime:\n max_turns: 1\n`);
const controller = new AbortController();
controller.abort();
const messages: any[] = [];
const drain = (async () => {
for await (const msg of query({
prompt: "hello",
dir: agentDir,
abortController: controller,
timeoutMs: 100,
})) {
messages.push(msg);
}
})();
let deadlineTimer: ReturnType<typeof setTimeout>;
const deadline = new Promise((_, reject) => {
deadlineTimer = setTimeout(
() => reject(new Error("aborted query did not settle")),
2000,
);
});
try {
await Promise.race([drain, deadline]);
} finally {
clearTimeout(deadlineTimer!);
}
assert.ok(messages.some((m) => m.type === "system"));
const assistantMessages = messages.filter((m) => m.type === "assistant");
assert.equal(messages.some((m) => m.type === "delta"), false);
assert.equal(assistantMessages.length, 1);
assert.equal(assistantMessages[0].stopReason, "aborted");
assert.equal(assistantMessages[0].usage.totalTokens, 0);
assert.equal(
assistantMessages[0].content,
"",
"a pre-aborted query must not produce model content",
);
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});

it("emits error system message when agent dir is invalid", async () => {
const messages: any[] = [];
for await (const msg of query({
Expand Down