@nathapp/nax-agent 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +56 -2
- package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
- package/dist/coding-tools/coding-tool-sandbox.js +14 -3
- package/dist/coding-tools/coding-tool-support.d.ts +3 -0
- package/dist/coding-tools/coding-tool-support.js +2 -0
- package/dist/index.d.ts +21 -3
- package/dist/index.js +13 -3
- package/dist/infra/stderr-tail.d.ts +8 -0
- package/dist/infra/stderr-tail.js +48 -0
- package/dist/internal/redact.d.ts +23 -0
- package/dist/internal/redact.js +60 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +8 -0
- package/dist/native/adapter-deps.d.ts +3 -3
- package/dist/native/adapter-deps.js +3 -3
- package/dist/native/client.d.ts +7 -1
- package/dist/native/client.js +1 -1
- package/dist/native/credentials/change-guard.d.ts +8 -1
- package/dist/native/credentials/change-guard.js +1 -1
- package/dist/native/credentials/fingerprint.d.ts +4 -1
- package/dist/native/credentials/fingerprint.js +6 -3
- package/dist/native/credentials/session-source.d.ts +12 -0
- package/dist/native/credentials/session-source.js +33 -0
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +2 -1
- package/dist/native/session/loop-events/types.d.ts +1 -1
- package/dist/native/session/loop-handlers.d.ts +3 -0
- package/dist/native/session/loop-handlers.js +1 -1
- package/dist/native/session/memory-transcript-store.d.ts +12 -0
- package/dist/native/session/memory-transcript-store.js +36 -0
- package/dist/native/session/session.d.ts +138 -96
- package/dist/native/session/session.js +107 -127
- package/dist/native/session/transcript-identity.d.ts +39 -0
- package/dist/native/session/transcript-identity.js +81 -0
- package/dist/native/session/transcript-store.d.ts +22 -21
- package/dist/native/session/transcript-store.js +59 -68
- package/dist/native/session/transcript-types.d.ts +54 -0
- package/dist/native/session/transcript-types.js +7 -0
- package/dist/native/session/truncation-handler.d.ts +4 -3
- package/dist/native/session/truncation-handler.js +7 -10
- package/dist/native/session/turn-complete-step.d.ts +4 -1
- package/dist/native/session/turn-complete-step.js +11 -4
- package/dist/native/session/turn-event-emitter.d.ts +48 -0
- package/dist/native/session/turn-event-emitter.js +134 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
- package/dist/native/session/turn-loop-round-trip.js +8 -4
- package/dist/native/session/turn-loop.js +27 -13
- package/dist/native/session/turn-result.d.ts +1 -1
- package/dist/native/session/turn-result.js +1 -1
- package/dist/native/session/turn-tool-batch.d.ts +3 -0
- package/dist/native/session/turn-tool-batch.js +14 -6
- package/dist/native/session/turn-types.d.ts +17 -1
- package/dist/native/session-adapter.d.ts +22 -4
- package/dist/native/session-adapter.js +79 -35
- package/dist/native/stream-complete.d.ts +17 -0
- package/dist/native/stream-complete.js +40 -0
- package/dist/permissions/approvals-link.js +5 -4
- package/dist/permissions/ask-chain.d.ts +1 -1
- package/dist/permissions/ask.d.ts +6 -0
- package/dist/permissions/ask.js +6 -0
- package/dist/permissions/index.d.ts +1 -1
- package/dist/permissions/index.js +1 -1
- package/dist/sandbox/policy-builder.d.ts +6 -1
- package/dist/sandbox/policy-builder.js +18 -14
- package/dist/session/agent-session-deps.d.ts +12 -0
- package/dist/session/agent-session-deps.js +28 -0
- package/dist/session/agent-session-errors.d.ts +11 -0
- package/dist/session/agent-session-errors.js +12 -0
- package/dist/session/agent-session-options.d.ts +12 -0
- package/dist/session/agent-session-options.js +96 -0
- package/dist/session/agent-session-resume.d.ts +14 -0
- package/dist/session/agent-session-resume.js +49 -0
- package/dist/session/agent-session-turn.d.ts +41 -0
- package/dist/session/agent-session-turn.js +177 -0
- package/dist/session/agent-session-types.d.ts +182 -0
- package/dist/session/agent-session-types.js +1 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +184 -0
- package/dist/session/native-backend-options.d.ts +24 -0
- package/dist/session/native-backend-options.js +81 -0
- package/dist/session/native-backend.d.ts +4 -0
- package/dist/session/native-backend.js +106 -0
- package/dist/session/pending-asks.d.ts +24 -0
- package/dist/session/pending-asks.js +77 -0
- package/dist/session/session-ask-link.d.ts +33 -0
- package/dist/session/session-ask-link.js +53 -0
- package/dist/session/session-ask-port.d.ts +12 -0
- package/dist/session/session-ask-port.js +64 -0
- package/dist/session/session-backend.d.ts +79 -0
- package/dist/session/session-backend.js +1 -0
- package/dist/session/session-event-channel.d.ts +17 -0
- package/dist/session/session-event-channel.js +132 -0
- package/dist/session/session-interaction.d.ts +20 -0
- package/dist/session/session-interaction.js +161 -0
- package/dist/session/session-tool-support.d.ts +48 -0
- package/dist/session/session-tool-support.js +104 -0
- package/dist/session/session-types.d.ts +33 -2
- package/dist/session/turn-event.d.ts +66 -0
- package/dist/session/turn-event.js +1 -0
- package/dist/tools/ask-request.js +3 -1
- package/dist/tools/bash-cwd.d.ts +3 -3
- package/dist/tools/credential-read-deny.d.ts +22 -0
- package/dist/tools/credential-read-deny.js +56 -0
- package/dist/tools/glob.js +9 -2
- package/dist/tools/grep.js +55 -26
- package/dist/tools/owned-paths.d.ts +43 -0
- package/dist/tools/owned-paths.js +17 -0
- package/dist/tools/package-managers.d.ts +3 -0
- package/dist/tools/package-managers.js +7 -3
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +49 -62
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +2 -1
- package/dist/tools/policy-paths-branch.d.ts +14 -8
- package/dist/tools/policy-paths-branch.js +20 -19
- package/dist/tools/policy.d.ts +10 -7
- package/dist/tools/policy.js +9 -7
- package/dist/tools/protected-paths.d.ts +6 -6
- package/dist/tools/read.js +38 -20
- package/dist/tools/registry.d.ts +3 -0
- package/dist/tools/run-command-exec.js +1 -0
- package/dist/tools/runtime.d.ts +3 -0
- package/dist/tools/runtime.js +1 -0
- package/dist/tools/scratchpad.js +2 -1
- package/dist/tools/types.d.ts +2 -2
- package/package.json +2 -2
- package/dist/tools/nax-owned-writes.d.ts +0 -135
- package/dist/tools/nax-owned-writes.js +0 -259
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's pending approvals and questions (spec 4.2 answer, 6.1). Each
|
|
3
|
+
* request settles once: by answer(), by its deadline, or by its turn signal.
|
|
4
|
+
* Ids are kept for the session's lifetime, so a late click gets a status
|
|
5
|
+
* rather than a throw; only a never-issued id or a kind mismatch throws.
|
|
6
|
+
*/
|
|
7
|
+
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
8
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
9
|
+
/** The kind a reply answers, or undefined for a malformed reply (callers may be plain JS). */
|
|
10
|
+
function replyKind(reply) {
|
|
11
|
+
const value = reply;
|
|
12
|
+
if (typeof value !== "object" || value === null)
|
|
13
|
+
return undefined;
|
|
14
|
+
if ("decision" in value)
|
|
15
|
+
return value.decision === "allow" || value.decision === "deny" ? "approval" : undefined;
|
|
16
|
+
return "text" in value && typeof value.text === "string" ? "question" : undefined;
|
|
17
|
+
}
|
|
18
|
+
function kindMismatch(requestId, kind) {
|
|
19
|
+
const expected = kind === "approval" ? "an approval: reply with { decision }" : "a question: reply with { text }";
|
|
20
|
+
return new AgentSessionError(`Request "${requestId}" is ${expected}`, "AGENT_SESSION_INVALID_ANSWER", {
|
|
21
|
+
requestId,
|
|
22
|
+
kind,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function createPendingAskTable(timeoutMs) {
|
|
26
|
+
const entries = new Map();
|
|
27
|
+
let closed = false;
|
|
28
|
+
function issue(kind, signal) {
|
|
29
|
+
const requestId = _agentSessionDeps.randomUUID();
|
|
30
|
+
const expiresAt = new Date(_agentSessionDeps.now() + timeoutMs).toISOString();
|
|
31
|
+
const settled = new Promise((resolve) => {
|
|
32
|
+
let timer;
|
|
33
|
+
const onAbort = () => finish({ by: "cancelled" });
|
|
34
|
+
function finish(settlement) {
|
|
35
|
+
if (entries.get(requestId)?.state !== "pending")
|
|
36
|
+
return;
|
|
37
|
+
_agentSessionDeps.clearTimeout(timer);
|
|
38
|
+
signal?.removeEventListener("abort", onAbort);
|
|
39
|
+
entries.set(requestId, { kind, state: settlement.by });
|
|
40
|
+
resolve(settlement);
|
|
41
|
+
}
|
|
42
|
+
entries.set(requestId, { kind, state: "pending", finish });
|
|
43
|
+
timer = _agentSessionDeps.setTimeout(() => finish({ by: "timeout" }), timeoutMs);
|
|
44
|
+
if (closed || signal?.aborted === true)
|
|
45
|
+
finish({ by: "cancelled" });
|
|
46
|
+
else
|
|
47
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
});
|
|
49
|
+
return { requestId, expiresAt, settled };
|
|
50
|
+
}
|
|
51
|
+
function answer(requestId, reply) {
|
|
52
|
+
if (closed)
|
|
53
|
+
return "unknown";
|
|
54
|
+
const entry = entries.get(requestId);
|
|
55
|
+
if (entry === undefined) {
|
|
56
|
+
throw new AgentSessionError(`No request "${requestId}" was issued by this session`, "AGENT_SESSION_INVALID_ANSWER", {
|
|
57
|
+
requestId,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (replyKind(reply) !== entry.kind)
|
|
61
|
+
throw kindMismatch(requestId, entry.kind);
|
|
62
|
+
if (entry.state === "pending") {
|
|
63
|
+
entry.finish?.({ by: "human", reply });
|
|
64
|
+
return "accepted";
|
|
65
|
+
}
|
|
66
|
+
return entry.state === "cancelled" ? "cancelled" : "expired";
|
|
67
|
+
}
|
|
68
|
+
function cancelAll() {
|
|
69
|
+
for (const entry of [...entries.values()])
|
|
70
|
+
entry.finish?.({ by: "cancelled" });
|
|
71
|
+
}
|
|
72
|
+
function close() {
|
|
73
|
+
cancelAll();
|
|
74
|
+
closed = true;
|
|
75
|
+
}
|
|
76
|
+
return { issue, answer, cancelAll, close };
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raises approvals to the person through the session's events (spec 6.1).
|
|
3
|
+
* askPerson is shared by the coding tools' AskLink and by embedder tools
|
|
4
|
+
* whose approval is "always". Asks are serial in practice: the tool batch
|
|
5
|
+
* runs one call at a time.
|
|
6
|
+
*/
|
|
7
|
+
import { type AskLink, type AskResolver } from "#src/permissions/index";
|
|
8
|
+
import type { SessionEventBody } from "./agent-session-types.ts";
|
|
9
|
+
import type { PendingAskTable } from "./pending-asks.ts";
|
|
10
|
+
import type { SessionAskPort } from "./session-backend.ts";
|
|
11
|
+
export interface SessionAskDeps {
|
|
12
|
+
readonly table: PendingAskTable;
|
|
13
|
+
/** Emits on the running turn's event stream. */
|
|
14
|
+
readonly emit: (body: SessionEventBody) => void;
|
|
15
|
+
}
|
|
16
|
+
export interface ApprovalAsk {
|
|
17
|
+
readonly tool: string;
|
|
18
|
+
readonly summary: string;
|
|
19
|
+
readonly command?: string;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly callId?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function askPerson(deps: SessionAskDeps, ask: ApprovalAsk, signal: AbortSignal | undefined): Promise<{
|
|
24
|
+
readonly decision: "allow" | "deny";
|
|
25
|
+
readonly decidedBy: "human" | "timeout" | "cancelled";
|
|
26
|
+
}>;
|
|
27
|
+
export interface SessionAskLinkDeps {
|
|
28
|
+
readonly port: SessionAskPort;
|
|
29
|
+
/** The tool call being answered right now, if any. */
|
|
30
|
+
readonly currentCallId: () => string | undefined;
|
|
31
|
+
}
|
|
32
|
+
export declare function createSessionAskLink(deps: SessionAskLinkDeps): AskLink;
|
|
33
|
+
export declare function createSessionAskResolver(link: AskLink): AskResolver;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raises approvals to the person through the session's events (spec 6.1).
|
|
3
|
+
* askPerson is shared by the coding tools' AskLink and by embedder tools
|
|
4
|
+
* whose approval is "always". Asks are serial in practice: the tool batch
|
|
5
|
+
* runs one call at a time.
|
|
6
|
+
*/
|
|
7
|
+
import { chainAskLinks, maskForPrompt, } from "#src/permissions/index";
|
|
8
|
+
export async function askPerson(deps, ask, signal) {
|
|
9
|
+
const { requestId, expiresAt, settled } = deps.table.issue("approval", signal);
|
|
10
|
+
deps.emit({
|
|
11
|
+
type: "approval_requested",
|
|
12
|
+
requestId,
|
|
13
|
+
...(ask.callId !== undefined ? { callId: ask.callId } : {}),
|
|
14
|
+
tool: ask.tool,
|
|
15
|
+
summary: ask.summary,
|
|
16
|
+
...(ask.command !== undefined ? { command: ask.command } : {}),
|
|
17
|
+
reason: ask.reason,
|
|
18
|
+
expiresAt,
|
|
19
|
+
});
|
|
20
|
+
const settlement = await settled;
|
|
21
|
+
const allowed = settlement.by === "human" && "decision" in settlement.reply && settlement.reply.decision === "allow";
|
|
22
|
+
const decision = allowed ? "allow" : "deny";
|
|
23
|
+
deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: settlement.by });
|
|
24
|
+
return { decision, decidedBy: settlement.by };
|
|
25
|
+
}
|
|
26
|
+
const UNSHOWABLE = { decision: "deny", decidedBy: "unshowable" };
|
|
27
|
+
export function createSessionAskLink(deps) {
|
|
28
|
+
return {
|
|
29
|
+
name: "agent-session",
|
|
30
|
+
resolve(req, control) {
|
|
31
|
+
// A request whose text could not be shown safely is never put in front of a person.
|
|
32
|
+
if (req.unshowable === true)
|
|
33
|
+
return Promise.resolve(UNSHOWABLE);
|
|
34
|
+
// As nax's human link: the command is masked, and one whose secret cannot be masked safely is unshowable.
|
|
35
|
+
const masked = req.command === undefined ? undefined : maskForPrompt(req.command);
|
|
36
|
+
if (masked !== undefined && !masked.ok)
|
|
37
|
+
return Promise.resolve(UNSHOWABLE);
|
|
38
|
+
const callId = deps.currentCallId();
|
|
39
|
+
return deps.port.requestApproval({
|
|
40
|
+
tool: req.tool,
|
|
41
|
+
summary: req.summary,
|
|
42
|
+
...(masked !== undefined ? { command: masked.masked } : {}),
|
|
43
|
+
reason: req.reason ?? `matched ${req.rule}`,
|
|
44
|
+
...(callId !== undefined ? { callId } : {}),
|
|
45
|
+
...(control?.signal !== undefined ? { signal: control.signal } : {}),
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function createSessionAskResolver(link) {
|
|
51
|
+
const chain = chainAskLinks([link]);
|
|
52
|
+
return { humanReachable: true, resolve: (req, control) => chain.resolve(req, control) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SessionEventBody } from "./agent-session-types.ts";
|
|
2
|
+
import type { PendingAskTable } from "./pending-asks.ts";
|
|
3
|
+
import type { SessionAskPort } from "./session-backend.ts";
|
|
4
|
+
export interface SessionAskPortDeps {
|
|
5
|
+
readonly table: PendingAskTable;
|
|
6
|
+
readonly emit: (body: SessionEventBody) => void;
|
|
7
|
+
readonly turn: () => {
|
|
8
|
+
readonly turnId: string;
|
|
9
|
+
readonly signal: AbortSignal;
|
|
10
|
+
} | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function createSessionAskPort(deps: SessionAskPortDeps): SessionAskPort;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's ask port (S4 spec 5.1): approvals and questions any backend
|
|
3
|
+
* raises, on top of the pending-ask table. A request needs a running turn: its
|
|
4
|
+
* events go on that turn's stream and its turn signal settles it. Profile
|
|
5
|
+
* auto-decisions and informational questions are emitted, never queued.
|
|
6
|
+
*/
|
|
7
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
8
|
+
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
9
|
+
import { askPerson } from "./session-ask-link.js";
|
|
10
|
+
const ALREADY_ABORTED = AbortSignal.abort();
|
|
11
|
+
function noTurn() {
|
|
12
|
+
return new NaxError("No turn is running; an approval needs a running turn", "AGENT_SESSION_TURN_FAILED", {
|
|
13
|
+
stage: "agent-session",
|
|
14
|
+
detail: "no-turn",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function createSessionAskPort(deps) {
|
|
18
|
+
const askDeps = { table: deps.table, emit: deps.emit };
|
|
19
|
+
const now = () => new Date(_agentSessionDeps.now()).toISOString();
|
|
20
|
+
return {
|
|
21
|
+
async requestApproval(req) {
|
|
22
|
+
const turn = deps.turn();
|
|
23
|
+
if (turn === undefined)
|
|
24
|
+
throw noTurn();
|
|
25
|
+
const signal = req.signal === undefined ? turn.signal : AbortSignal.any([turn.signal, req.signal]);
|
|
26
|
+
const { signal: _ignored, ...ask } = req;
|
|
27
|
+
return askPerson(askDeps, ask, signal);
|
|
28
|
+
},
|
|
29
|
+
// Informational only. The requestId is emitted but never put in the pending
|
|
30
|
+
// table, so session.answer() on it is not answerable, and expiresAt is the
|
|
31
|
+
// emit time (the auto-decision is already resolved), not a real deadline.
|
|
32
|
+
recordAutoDecision(req, decision) {
|
|
33
|
+
if (deps.turn() === undefined)
|
|
34
|
+
return;
|
|
35
|
+
const requestId = _agentSessionDeps.randomUUID();
|
|
36
|
+
deps.emit({
|
|
37
|
+
type: "approval_requested",
|
|
38
|
+
requestId,
|
|
39
|
+
...(req.callId !== undefined ? { callId: req.callId } : {}),
|
|
40
|
+
tool: req.tool,
|
|
41
|
+
summary: req.summary,
|
|
42
|
+
reason: req.reason,
|
|
43
|
+
expiresAt: now(),
|
|
44
|
+
});
|
|
45
|
+
deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: "profile" });
|
|
46
|
+
},
|
|
47
|
+
async askQuestion(text, opts) {
|
|
48
|
+
const turn = deps.turn();
|
|
49
|
+
if (turn === undefined)
|
|
50
|
+
return null;
|
|
51
|
+
const signal = opts?.signal === undefined ? turn.signal : AbortSignal.any([turn.signal, opts.signal]);
|
|
52
|
+
const { requestId, expiresAt, settled } = deps.table.issue("question", signal);
|
|
53
|
+
deps.emit({ type: "question", requestId, text, expiresAt });
|
|
54
|
+
const settlement = await settled;
|
|
55
|
+
return settlement.by === "human" && "text" in settlement.reply ? settlement.reply.text : null;
|
|
56
|
+
},
|
|
57
|
+
noteQuestion(text) {
|
|
58
|
+
if (deps.turn() === undefined)
|
|
59
|
+
return;
|
|
60
|
+
const { requestId } = deps.table.issue("question", ALREADY_ABORTED);
|
|
61
|
+
deps.emit({ type: "question", requestId, text, expiresAt: now() });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backend seam (S4 spec §5.1). A `SessionBackend` opens an
|
|
3
|
+
* `OpenedBackend`: the S1 `AgentSessionAdapter` plus the per-turn options it
|
|
4
|
+
* contributes, the ask port it reaches the facade through, and the info the
|
|
5
|
+
* facade records. The native backend and the out-of-tree ACP backend both
|
|
6
|
+
* satisfy this contract; nothing here is backend-specific.
|
|
7
|
+
*/
|
|
8
|
+
import type { TranscriptDoc, TranscriptStore } from "#src/native/session/transcript-types";
|
|
9
|
+
import type { AgentSessionProfile, ApprovalDecidedBy, EmbedderTool } from "./agent-session-types.ts";
|
|
10
|
+
import type { AgentSessionAdapter, SendTurnOpts, SessionHandle } from "./session-types.ts";
|
|
11
|
+
export interface BackendInfo {
|
|
12
|
+
/** "native" or "acp:<agent>"; recorded in the transcript document. */
|
|
13
|
+
readonly kind: string;
|
|
14
|
+
/** Read-only, JSON-safe capability summary. `{}` for native. */
|
|
15
|
+
readonly capabilities: Readonly<Record<string, unknown>>;
|
|
16
|
+
}
|
|
17
|
+
export interface ApprovalRequest {
|
|
18
|
+
readonly callId?: string;
|
|
19
|
+
readonly tool: string;
|
|
20
|
+
readonly summary: string;
|
|
21
|
+
readonly command?: string;
|
|
22
|
+
readonly reason: string;
|
|
23
|
+
/** Extra abort source, combined with the turn signal (for example a tool-level abort). */
|
|
24
|
+
readonly signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export interface SessionAskPort {
|
|
27
|
+
/** Throws NaxError AGENT_SESSION_TURN_FAILED (context.detail "no-turn") when no turn is running. */
|
|
28
|
+
requestApproval(req: ApprovalRequest): Promise<{
|
|
29
|
+
readonly decision: "allow" | "deny";
|
|
30
|
+
readonly decidedBy: ApprovalDecidedBy;
|
|
31
|
+
}>;
|
|
32
|
+
/** Emits approval_requested then approval_resolved (decidedBy "profile"). No-op when no turn is running. */
|
|
33
|
+
recordAutoDecision(req: Omit<ApprovalRequest, "command" | "signal">, decision: "allow" | "deny"): void;
|
|
34
|
+
/**
|
|
35
|
+
* The person's text, or null on deadline, cancel or no running turn. `opts.signal`
|
|
36
|
+
* is an extra abort source combined with the turn signal, as for approvals (for
|
|
37
|
+
* example a backend's per-request scope that ends before the turn does).
|
|
38
|
+
*/
|
|
39
|
+
askQuestion(text: string, opts?: {
|
|
40
|
+
readonly signal?: AbortSignal;
|
|
41
|
+
}): Promise<string | null>;
|
|
42
|
+
/** An informational question event; answer() on its id returns "cancelled". No-op when no turn is running. */
|
|
43
|
+
noteQuestion(text: string): void;
|
|
44
|
+
}
|
|
45
|
+
/** What a backend contributes to every sendTurn. */
|
|
46
|
+
export type TurnContribution = Pick<SendTurnOpts, "interactionHandler"> & Partial<Pick<SendTurnOpts, "codingTools" | "loopHandlers" | "loopHandlerContext">>;
|
|
47
|
+
export interface BackendOpenContext {
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
/** The facade's resolved root: the workdir, or a private scratch root for profile "none". */
|
|
50
|
+
readonly workdir: string;
|
|
51
|
+
readonly profile: AgentSessionProfile;
|
|
52
|
+
readonly instructions: string | undefined;
|
|
53
|
+
readonly tools: readonly EmbedderTool[];
|
|
54
|
+
readonly transcriptStore: TranscriptStore;
|
|
55
|
+
/** The stored document on resume (already presence-, schema- and kind-checked by the facade). */
|
|
56
|
+
readonly resume: {
|
|
57
|
+
readonly doc: TranscriptDoc;
|
|
58
|
+
} | undefined;
|
|
59
|
+
readonly asks: SessionAskPort;
|
|
60
|
+
/** Re-read at use time: the running turn's signal, or a never-aborting one between turns. */
|
|
61
|
+
readonly turnSignal: () => AbortSignal;
|
|
62
|
+
readonly currentTurnId: () => string | undefined;
|
|
63
|
+
readonly turnTimeoutSeconds: number;
|
|
64
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
65
|
+
/** Aborted when the session starts closing. */
|
|
66
|
+
readonly openSignal: AbortSignal;
|
|
67
|
+
}
|
|
68
|
+
export interface OpenedBackend {
|
|
69
|
+
readonly adapter: AgentSessionAdapter;
|
|
70
|
+
readonly handle: SessionHandle;
|
|
71
|
+
readonly info: BackendInfo;
|
|
72
|
+
turnOpts(): TurnContribution;
|
|
73
|
+
/** Releases backend resources after adapter.closeSession. Idempotent. */
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export interface SessionBackend {
|
|
77
|
+
readonly kind: string;
|
|
78
|
+
open(ctx: BackendOpenContext): Promise<OpenedBackend>;
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionEvent } from "./agent-session-types.ts";
|
|
2
|
+
export interface SessionEventChannelOptions {
|
|
3
|
+
/** Undelivered control events tolerated before onStall fires. */
|
|
4
|
+
readonly controlCap: number;
|
|
5
|
+
/** The consumer's first next(): the turn starts here. May push synchronously. */
|
|
6
|
+
readonly onFirstPull: () => void;
|
|
7
|
+
/** The consumer called return() before the channel ended. */
|
|
8
|
+
readonly onReturn: () => void;
|
|
9
|
+
readonly onStall: () => void;
|
|
10
|
+
}
|
|
11
|
+
export interface SessionEventChannel {
|
|
12
|
+
push(event: SessionEvent): void;
|
|
13
|
+
/** No more events: the iterator completes once the buffer drains. */
|
|
14
|
+
end(): void;
|
|
15
|
+
readonly iterator: AsyncIterator<SessionEvent>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createSessionEventChannel(options: SessionEventChannelOptions): SessionEventChannel;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The push-to-pull channel behind send()'s iterator (spec 4.4). The turn
|
|
3
|
+
* pushes; one consumer pulls. While the consumer lags, adjacent deltas of the
|
|
4
|
+
* same type and round merge. Control events are never merged or dropped; past
|
|
5
|
+
* `controlCap` undelivered ones the channel reports a stall, once. One
|
|
6
|
+
* consumer only: a second next() while one is pending rejects (for await
|
|
7
|
+
* never makes one). return() before the first next() never starts the turn.
|
|
8
|
+
*
|
|
9
|
+
* The buffer is two slots: a `queue` of non-mergeable events (FIFO, indexed)
|
|
10
|
+
* and a `tail` that is always a delta the next push can merge into. push is
|
|
11
|
+
* amortized O(1) — a `queue.push` and the `tail` swap are O(1), and the
|
|
12
|
+
* merge's string concat is O(merged-text length), which is the same cost the
|
|
13
|
+
* old design paid, just without the per-merge buffer copy. The pop is a
|
|
14
|
+
* `queue[head++]` read, not an `Array.shift()`. The old implementation
|
|
15
|
+
* copied the whole buffer on every merge (`[...buffer.slice(0, -1), combined]`),
|
|
16
|
+
* which made a high-rate delta stream quadratic.
|
|
17
|
+
*/
|
|
18
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
19
|
+
const DONE = { done: true, value: undefined };
|
|
20
|
+
function isDelta(event) {
|
|
21
|
+
return event !== undefined && (event.type === "text_delta" || event.type === "thinking_delta");
|
|
22
|
+
}
|
|
23
|
+
function canMergeInto(into, next) {
|
|
24
|
+
return into.type === next.type && into.round === next.round;
|
|
25
|
+
}
|
|
26
|
+
export function createSessionEventChannel(options) {
|
|
27
|
+
let queue = [];
|
|
28
|
+
let head = 0;
|
|
29
|
+
let tail;
|
|
30
|
+
let waiter;
|
|
31
|
+
let undeliveredControl = 0;
|
|
32
|
+
let ended = false;
|
|
33
|
+
let consumerGone = false;
|
|
34
|
+
let pulled = false;
|
|
35
|
+
let stalled = false;
|
|
36
|
+
function deliver(result) {
|
|
37
|
+
if (waiter === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
const resolve = waiter;
|
|
40
|
+
waiter = undefined;
|
|
41
|
+
resolve(result);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
/** Drop the consumed prefix; O(1) when the buffer is fully drained. */
|
|
45
|
+
function compact() {
|
|
46
|
+
if (head > 0 && head === queue.length) {
|
|
47
|
+
queue = [];
|
|
48
|
+
head = 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function enqueue(event) {
|
|
52
|
+
if (isDelta(event)) {
|
|
53
|
+
if (tail !== undefined && canMergeInto(tail, event)) {
|
|
54
|
+
// Replace, don't mutate: DeltaEvent.text is readonly. O(1) — a
|
|
55
|
+
// string concat, not a buffer copy.
|
|
56
|
+
tail = { ...tail, text: tail.text + event.text };
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (tail !== undefined)
|
|
60
|
+
queue.push(tail);
|
|
61
|
+
tail = event;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Control events: never held in `tail` (so a later delta sees no merge
|
|
65
|
+
// partner and the cap math stays correct).
|
|
66
|
+
if (tail !== undefined) {
|
|
67
|
+
queue.push(tail);
|
|
68
|
+
tail = undefined;
|
|
69
|
+
}
|
|
70
|
+
queue.push(event);
|
|
71
|
+
undeliveredControl += 1;
|
|
72
|
+
if (!stalled && undeliveredControl > options.controlCap) {
|
|
73
|
+
stalled = true;
|
|
74
|
+
options.onStall();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function push(event) {
|
|
78
|
+
if (ended || consumerGone)
|
|
79
|
+
return;
|
|
80
|
+
if (deliver({ value: event, done: false }))
|
|
81
|
+
return;
|
|
82
|
+
enqueue(event);
|
|
83
|
+
}
|
|
84
|
+
function end() {
|
|
85
|
+
if (ended)
|
|
86
|
+
return;
|
|
87
|
+
ended = true;
|
|
88
|
+
if (queue.length === head && tail === undefined)
|
|
89
|
+
deliver(DONE);
|
|
90
|
+
}
|
|
91
|
+
async function next() {
|
|
92
|
+
if (consumerGone)
|
|
93
|
+
return DONE;
|
|
94
|
+
if (waiter !== undefined) {
|
|
95
|
+
throw new AgentSessionError("send() iterables support one next() at a time", "AGENT_SESSION_BUSY");
|
|
96
|
+
}
|
|
97
|
+
if (!pulled) {
|
|
98
|
+
pulled = true;
|
|
99
|
+
options.onFirstPull();
|
|
100
|
+
}
|
|
101
|
+
if (head < queue.length) {
|
|
102
|
+
const event = queue[head++];
|
|
103
|
+
compact();
|
|
104
|
+
if (!isDelta(event))
|
|
105
|
+
undeliveredControl -= 1;
|
|
106
|
+
return { value: event, done: false };
|
|
107
|
+
}
|
|
108
|
+
if (tail !== undefined) {
|
|
109
|
+
const event = tail;
|
|
110
|
+
tail = undefined;
|
|
111
|
+
return { value: event, done: false };
|
|
112
|
+
}
|
|
113
|
+
if (ended)
|
|
114
|
+
return DONE;
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
waiter = resolve;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function leave() {
|
|
120
|
+
if (consumerGone)
|
|
121
|
+
return DONE;
|
|
122
|
+
consumerGone = true;
|
|
123
|
+
queue = [];
|
|
124
|
+
head = 0;
|
|
125
|
+
tail = undefined;
|
|
126
|
+
deliver(DONE);
|
|
127
|
+
if (!ended)
|
|
128
|
+
options.onReturn();
|
|
129
|
+
return DONE;
|
|
130
|
+
}
|
|
131
|
+
return { push, end, iterator: { next, return: leave } };
|
|
132
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CodingTool } from "#src/tools/registry";
|
|
2
|
+
import type { CodingToolRuntime } from "#src/tools/runtime";
|
|
3
|
+
import type { EmbedderTool } from "./agent-session-types.ts";
|
|
4
|
+
import type { InteractionHandler } from "./interaction-handler.ts";
|
|
5
|
+
import type { SessionAskPort } from "./session-backend.ts";
|
|
6
|
+
/** Byte cap on an embedder approval's default summary. */
|
|
7
|
+
export declare const EMBEDDER_SUMMARY_BYTES = 1024;
|
|
8
|
+
export interface SessionInteractionDeps {
|
|
9
|
+
readonly sessionId: string;
|
|
10
|
+
readonly runtime: CodingToolRuntime;
|
|
11
|
+
readonly embedderTools: ReadonlyMap<string, EmbedderTool>;
|
|
12
|
+
readonly asks: SessionAskPort;
|
|
13
|
+
/** The running turn's signal (a never-aborting one between turns). */
|
|
14
|
+
readonly turnSignal: () => AbortSignal;
|
|
15
|
+
readonly setCurrentCallId: (callId: string | undefined) => void;
|
|
16
|
+
}
|
|
17
|
+
/** What the model sees of an embedder tool. The facade's handler runs it; this run is never reached. */
|
|
18
|
+
export declare function embedderToolDescriptor(tool: EmbedderTool): CodingTool;
|
|
19
|
+
export declare function defaultSummary(input: unknown): string;
|
|
20
|
+
export declare function createSessionInteractionHandler(deps: SessionInteractionDeps): InteractionHandler;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facade's InteractionHandler (spec 5.1, 4.3, 6.1). Routes each loop
|
|
3
|
+
* request:
|
|
4
|
+
* - question -> the pending-ask table (null on timeout or cancel, which the
|
|
5
|
+
* loop answers with its no-operator text);
|
|
6
|
+
* - an embedder tool -> its own run, after an approval when it is "always";
|
|
7
|
+
* - a built-in coding tool -> the session's CodingToolRuntime, with the call
|
|
8
|
+
* id in the current-call slot so a policy ask can name it.
|
|
9
|
+
* A failed tool is reported by throwing: the tool batch turns a handler throw
|
|
10
|
+
* into an isError result whose content is the thrown message.
|
|
11
|
+
*/
|
|
12
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
13
|
+
import { capStrings, redactSecrets } from "#src/internal/redact";
|
|
14
|
+
import { askDenyReason } from "#src/tools/ask-request";
|
|
15
|
+
import { cutToByteCap } from "#src/tools/truncate";
|
|
16
|
+
/** Byte cap on an embedder approval's default summary. */
|
|
17
|
+
export const EMBEDDER_SUMMARY_BYTES = 1024;
|
|
18
|
+
/**
|
|
19
|
+
* Redaction scans at most this many bytes of an embedder tool input; the
|
|
20
|
+
* summary keeps far fewer. Same 16x pattern as `turn-event-emitter.ts`'s
|
|
21
|
+
* `REDACTION_SCAN_BYTES` so the bound is comparable to a tool-call input.
|
|
22
|
+
*/
|
|
23
|
+
const EMBEDDER_SCAN_BYTES = EMBEDDER_SUMMARY_BYTES * 16;
|
|
24
|
+
/** What the model sees of an embedder tool. The facade's handler runs it; this run is never reached. */
|
|
25
|
+
export function embedderToolDescriptor(tool) {
|
|
26
|
+
return {
|
|
27
|
+
name: tool.name,
|
|
28
|
+
description: tool.description,
|
|
29
|
+
inputSchema: tool.inputSchema,
|
|
30
|
+
scope: { pathFields: [] },
|
|
31
|
+
async run() {
|
|
32
|
+
return { content: `"${tool.name}" runs through the agent session, not the coding-tool runtime.`, isError: true };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function defaultSummary(input) {
|
|
37
|
+
let json;
|
|
38
|
+
try {
|
|
39
|
+
// Cap before redactSecrets walks: a multi-MB tool input must not run
|
|
40
|
+
// every SECRET_VALUE_PATTERNS regex (PEM block up to 64KB per match)
|
|
41
|
+
// over megabytes. The post-serialize cutToByteCap is the display cap;
|
|
42
|
+
// this is the scan cap.
|
|
43
|
+
json = JSON.stringify(redactSecrets(capStrings(input, EMBEDDER_SCAN_BYTES))) ?? "null";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "[input not serializable]";
|
|
47
|
+
}
|
|
48
|
+
return cutToByteCap(json, EMBEDDER_SUMMARY_BYTES);
|
|
49
|
+
}
|
|
50
|
+
function toolError(message, tool) {
|
|
51
|
+
return new NaxError(message, "AGENT_SESSION_TOOL_ERROR", { stage: "agent-session", tool });
|
|
52
|
+
}
|
|
53
|
+
function summaryFor(tool, input) {
|
|
54
|
+
if (tool.describe === undefined)
|
|
55
|
+
return defaultSummary(input);
|
|
56
|
+
try {
|
|
57
|
+
// Same bound as defaultSummary: cap first, then redact, then display-cut.
|
|
58
|
+
// capStrings preserves the string's type at runtime; the cast is the
|
|
59
|
+
// single point where the generic is recovered for cutToByteCap.
|
|
60
|
+
const described = redactSecrets(capStrings(String(tool.describe(input)), EMBEDDER_SCAN_BYTES));
|
|
61
|
+
return cutToByteCap(described, EMBEDDER_SUMMARY_BYTES);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return defaultSummary(input);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function runSafely(tool, input, ctx) {
|
|
68
|
+
return Promise.resolve()
|
|
69
|
+
.then(() => tool.run(input, ctx))
|
|
70
|
+
.catch((err) => {
|
|
71
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
72
|
+
return { content: `Tool "${tool.name}" failed: ${cause}`, isError: true };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Runs the tool, but answers at once when the turn aborts: the batch awaits
|
|
77
|
+
* this handler with no abort race of its own, so a run that ignores its signal
|
|
78
|
+
* would otherwise hang the turn. The abandoned run's late settlement is ignored.
|
|
79
|
+
*/
|
|
80
|
+
async function invoke(tool, input, ctx) {
|
|
81
|
+
const abandoned = {
|
|
82
|
+
content: `Tool "${tool.name}" was abandoned: the turn ended.`,
|
|
83
|
+
isError: true,
|
|
84
|
+
};
|
|
85
|
+
if (ctx.signal.aborted)
|
|
86
|
+
return abandoned;
|
|
87
|
+
let onAbort = () => { };
|
|
88
|
+
const aborted = new Promise((resolve) => {
|
|
89
|
+
onAbort = () => resolve(abandoned);
|
|
90
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([runSafely(tool, input, ctx), aborted]);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function runEmbedderTool(deps, tool, request) {
|
|
100
|
+
const toolCallId = request.toolCallId ?? "";
|
|
101
|
+
const signal = request.signal ?? deps.turnSignal();
|
|
102
|
+
const input = request.input ?? {};
|
|
103
|
+
if (tool.approval === "always") {
|
|
104
|
+
const ask = {
|
|
105
|
+
tool: tool.name,
|
|
106
|
+
summary: summaryFor(tool, input),
|
|
107
|
+
reason: `"${tool.name}" asks before every run`,
|
|
108
|
+
callId: toolCallId,
|
|
109
|
+
};
|
|
110
|
+
const outcome = await deps.asks.requestApproval({ ...ask, signal });
|
|
111
|
+
if (outcome.decision !== "allow") {
|
|
112
|
+
const reason = askDenyReason(outcome.decidedBy);
|
|
113
|
+
return { answer: `Denied: ${reason}`, denied: { reason, breach: false } };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const result = await invoke(tool, input, { sessionId: deps.sessionId, toolCallId, signal });
|
|
117
|
+
if (result.isError === true)
|
|
118
|
+
throw toolError(result.content, tool.name);
|
|
119
|
+
return { answer: result.content };
|
|
120
|
+
}
|
|
121
|
+
function toolCallContext(request) {
|
|
122
|
+
return {
|
|
123
|
+
...(request.turnId !== undefined ? { turnId: request.turnId } : {}),
|
|
124
|
+
...(request.roundTrips !== undefined ? { roundTrips: request.roundTrips } : {}),
|
|
125
|
+
...(request.toolCallId !== undefined ? { toolCallId: request.toolCallId } : {}),
|
|
126
|
+
...(request.deferModelTruncation !== undefined ? { deferModelTruncation: request.deferModelTruncation } : {}),
|
|
127
|
+
...(request.signal !== undefined ? { signal: request.signal } : {}),
|
|
128
|
+
...(request.onWaiting !== undefined ? { onWaiting: request.onWaiting } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function runCodingTool(deps, request) {
|
|
132
|
+
deps.setCurrentCallId(request.toolCallId);
|
|
133
|
+
try {
|
|
134
|
+
const outcome = await deps.runtime.callTool(request.name, request.input ?? {}, toolCallContext(request));
|
|
135
|
+
if (outcome.kind === "denied") {
|
|
136
|
+
return { answer: `Denied: ${outcome.reason}`, denied: { reason: outcome.reason, breach: outcome.breach } };
|
|
137
|
+
}
|
|
138
|
+
if (outcome.kind === "error")
|
|
139
|
+
throw toolError(outcome.content, request.name);
|
|
140
|
+
return { answer: outcome.content };
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
deps.setCurrentCallId(undefined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function answerQuestion(deps, text) {
|
|
147
|
+
const answer = await deps.asks.askQuestion(text);
|
|
148
|
+
return answer === null ? null : { answer };
|
|
149
|
+
}
|
|
150
|
+
export function createSessionInteractionHandler(deps) {
|
|
151
|
+
return {
|
|
152
|
+
async onInteraction(request) {
|
|
153
|
+
if (request.kind === "question")
|
|
154
|
+
return answerQuestion(deps, request.text);
|
|
155
|
+
if (request.kind === "context-tool")
|
|
156
|
+
throw toolError(`Unknown tool "${request.name}"`, request.name);
|
|
157
|
+
const tool = deps.embedderTools.get(request.name);
|
|
158
|
+
return tool !== undefined ? runEmbedderTool(deps, tool, request) : runCodingTool(deps, request);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|