@gaunt-sloth/agent 2.0.0-alpha.34 → 2.0.0-alpha.36

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.
Files changed (63) hide show
  1. package/README.md +9 -10
  2. package/cli-acp.js +20 -24
  3. package/dist/builtInToolsConfig.d.ts +2 -2
  4. package/dist/builtInToolsConfig.js +2 -2
  5. package/dist/core/debugCapture.d.ts +4 -5
  6. package/dist/core/debugCapture.js.map +1 -1
  7. package/dist/core/resolveAgentFactory.d.ts +10 -9
  8. package/dist/core/resolveAgentFactory.js +11 -12
  9. package/dist/core/resolveAgentFactory.js.map +1 -1
  10. package/dist/index.d.ts +2 -4
  11. package/dist/index.js +4 -5
  12. package/dist/index.js.map +1 -1
  13. package/dist/middleware/frontendImageInjectionMiddleware.d.ts +3 -2
  14. package/dist/middleware/frontendImageInjectionMiddleware.js +3 -2
  15. package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -1
  16. package/dist/middleware/registry.js +16 -3
  17. package/dist/middleware/registry.js.map +1 -1
  18. package/dist/modules/acp/acpAgentApp.d.ts +93 -0
  19. package/dist/modules/acp/acpAgentApp.js +593 -0
  20. package/dist/modules/acp/acpAgentApp.js.map +1 -0
  21. package/dist/modules/acp/acpPermissions.d.ts +60 -0
  22. package/dist/modules/acp/acpPermissions.js +158 -0
  23. package/dist/modules/acp/acpPermissions.js.map +1 -0
  24. package/dist/modules/acp/acpStdio.d.ts +41 -0
  25. package/dist/modules/acp/acpStdio.js +65 -0
  26. package/dist/modules/acp/acpStdio.js.map +1 -0
  27. package/dist/modules/acp/acpUpdates.d.ts +101 -0
  28. package/dist/modules/acp/acpUpdates.js +287 -0
  29. package/dist/modules/acp/acpUpdates.js.map +1 -0
  30. package/dist/modules/apiAgUiModule.js +8 -16
  31. package/dist/modules/apiAgUiModule.js.map +1 -1
  32. package/dist/modules/interactiveSessionModule.js +39 -6
  33. package/dist/modules/interactiveSessionModule.js.map +1 -1
  34. package/dist/tools/GthCustomToolkit.js +1 -1
  35. package/dist/tools/GthCustomToolkit.js.map +1 -1
  36. package/dist/tools/GthDevToolkit.d.ts +2 -2
  37. package/dist/tools/GthDevToolkit.js +8 -13
  38. package/dist/tools/GthDevToolkit.js.map +1 -1
  39. package/dist/tools/gthChecklistTool.js +4 -5
  40. package/dist/tools/gthChecklistTool.js.map +1 -1
  41. package/dist/tools/gthGrepTool.js +6 -6
  42. package/dist/tools/shell/workDir.d.ts +1 -10
  43. package/dist/tools/shell/workDir.js +10 -36
  44. package/dist/tools/shell/workDir.js.map +1 -1
  45. package/package.json +8 -9
  46. package/dist/core/GthDeepAgent.d.ts +0 -128
  47. package/dist/core/GthDeepAgent.js +0 -712
  48. package/dist/core/GthDeepAgent.js.map +0 -1
  49. package/dist/core/deepAgentPermissions.d.ts +0 -138
  50. package/dist/core/deepAgentPermissions.js +0 -309
  51. package/dist/core/deepAgentPermissions.js.map +0 -1
  52. package/dist/core/gthAcpServer.d.ts +0 -27
  53. package/dist/core/gthAcpServer.js +0 -82
  54. package/dist/core/gthAcpServer.js.map +0 -1
  55. package/dist/core/gthDeepAgentFactory.d.ts +0 -11
  56. package/dist/core/gthDeepAgentFactory.js +0 -17
  57. package/dist/core/gthDeepAgentFactory.js.map +0 -1
  58. package/dist/core/subagentProfiles.d.ts +0 -50
  59. package/dist/core/subagentProfiles.js +0 -76
  60. package/dist/core/subagentProfiles.js.map +0 -1
  61. package/dist/modules/acpModule.d.ts +0 -40
  62. package/dist/modules/acpModule.js +0 -86
  63. package/dist/modules/acpModule.js.map +0 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Bridges the agent's tool-approval gate to ACP v2's `session/request_permission`.
4
+ *
5
+ * **This is the reason the ACP server drives {@link GthAgentRunner} rather than an agent
6
+ * directly.** The runner is what owns the gate — the bypass check, the allow-list, the AI rater,
7
+ * the deny list, and finally the human callback — and a surface that skips it does not get a
8
+ * quieter gate, it gets a gated tool that silently does nothing (the hole [[EXT-54]] records
9
+ * against the AG-UI and the old ACP servers). Everything here is the last hop of that gate: the
10
+ * runner has already decided the call needs a human, and this turns "needs a human" into a request
11
+ * an editor can put in front of one, then turns the answer back into a decision the runner
12
+ * understands.
13
+ *
14
+ * ## Untrusted text crosses this boundary as DATA, deliberately
15
+ *
16
+ * The command, the rater's reason and the escalation provenance are all model- or third-party-
17
+ * authored. On the terminal surfaces they are painted through `core/shell/framing` because a
18
+ * carriage return there reaches column 0 and an escape sequence clears the screen. **That renderer
19
+ * is not applied here, and its absence is not an oversight:** these values leave as JSON fields of
20
+ * a structured request, and the client draws them in its own UI, where terminal control codes are
21
+ * inert. Framing them would ship a line-number gutter and box drawing into a GUI. What matters on
22
+ * this surface is that they stay in their own fields rather than being interpolated into the
23
+ * prompt's own prose — so a client can style them as untrusted, and nothing the model writes can
24
+ * impersonate the request's title.
25
+ */
26
+ import type { PermissionOption, RequestPermissionOutcome, RequestPermissionRequest, SessionId } from '@agentclientprotocol/sdk/experimental/v2';
27
+ import type { PendingToolInterrupt, ToolApprovalDecision } from '@gaunt-sloth/core/core/types.js';
28
+ /**
29
+ * The four answers offered on every permission request, one per ACP `PermissionOptionKind`.
30
+ *
31
+ * The set is fixed rather than computed from what the gate would accept, because a menu that
32
+ * appears and disappears per call is a menu a user cannot learn. What each one MEANS is in
33
+ * {@link decisionForOutcome}; the labels say it in words, since a bare kind hint does not tell
34
+ * anyone what "always" persists to.
35
+ */
36
+ export declare const ACP_PERMISSION_OPTIONS: readonly PermissionOption[];
37
+ /** The ACP permission request for one pending tool call the gate escalated to a human. */
38
+ export declare function permissionRequestFor(options: {
39
+ sessionId: SessionId;
40
+ pending: PendingToolInterrupt;
41
+ /** The tool call id the update stream already announced for this call, when it is known. */
42
+ toolCallId?: string;
43
+ /** The session's working directory — the `cwd` a `command` subject is required to carry. */
44
+ cwd: string;
45
+ }): RequestPermissionRequest;
46
+ /**
47
+ * The gate decision an ACP permission outcome means.
48
+ *
49
+ * **Every path that is not an explicit allow is a reject**, including an option id we do not
50
+ * recognise and an outcome variant that postdates this code. That polarity is the whole safety
51
+ * property: a client that answers with something unexpected must not be able to run a command,
52
+ * and a future ACP outcome must fail toward refusing rather than toward executing.
53
+ *
54
+ * `allow-always` maps to the `always` scope, which persists to the project allow-list — the same
55
+ * thing the terminal menu's *always approve* does. `reject-always` maps to the `session` scope
56
+ * because that is the strongest refusal the gate actually persists; ACP's `reject_always` is a UI
57
+ * hint, and promising a persistence nothing implements would be worse than serving it with the
58
+ * refusal that does exist. The option's own label says "for this session" so the user is told.
59
+ */
60
+ export declare function decisionForOutcome(outcome: RequestPermissionOutcome): ToolApprovalDecision;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Bridges the agent's tool-approval gate to ACP v2's `session/request_permission`.
4
+ *
5
+ * **This is the reason the ACP server drives {@link GthAgentRunner} rather than an agent
6
+ * directly.** The runner is what owns the gate — the bypass check, the allow-list, the AI rater,
7
+ * the deny list, and finally the human callback — and a surface that skips it does not get a
8
+ * quieter gate, it gets a gated tool that silently does nothing (the hole [[EXT-54]] records
9
+ * against the AG-UI and the old ACP servers). Everything here is the last hop of that gate: the
10
+ * runner has already decided the call needs a human, and this turns "needs a human" into a request
11
+ * an editor can put in front of one, then turns the answer back into a decision the runner
12
+ * understands.
13
+ *
14
+ * ## Untrusted text crosses this boundary as DATA, deliberately
15
+ *
16
+ * The command, the rater's reason and the escalation provenance are all model- or third-party-
17
+ * authored. On the terminal surfaces they are painted through `core/shell/framing` because a
18
+ * carriage return there reaches column 0 and an escape sequence clears the screen. **That renderer
19
+ * is not applied here, and its absence is not an oversight:** these values leave as JSON fields of
20
+ * a structured request, and the client draws them in its own UI, where terminal control codes are
21
+ * inert. Framing them would ship a line-number gutter and box drawing into a GUI. What matters on
22
+ * this surface is that they stay in their own fields rather than being interpolated into the
23
+ * prompt's own prose — so a client can style them as untrusted, and nothing the model writes can
24
+ * impersonate the request's title.
25
+ */
26
+ import { toolKindFor } from '#src/modules/acp/acpUpdates.js';
27
+ /** The gated tool whose argument is a shell command, and so has an ACP `command` subject. */
28
+ const SHELL_TOOL = 'run_shell_command';
29
+ /**
30
+ * The four answers offered on every permission request, one per ACP `PermissionOptionKind`.
31
+ *
32
+ * The set is fixed rather than computed from what the gate would accept, because a menu that
33
+ * appears and disappears per call is a menu a user cannot learn. What each one MEANS is in
34
+ * {@link decisionForOutcome}; the labels say it in words, since a bare kind hint does not tell
35
+ * anyone what "always" persists to.
36
+ */
37
+ export const ACP_PERMISSION_OPTIONS = [
38
+ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
39
+ { optionId: 'allow-always', name: 'Allow and remember', kind: 'allow_always' },
40
+ { optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' },
41
+ { optionId: 'reject-always', name: 'Reject for this session', kind: 'reject_always' },
42
+ ];
43
+ /** The shell command a pending call would run, when it is one and it is a plain string. */
44
+ function shellCommandOf(pending) {
45
+ if (pending.name !== SHELL_TOOL)
46
+ return undefined;
47
+ const command = pending.args.command;
48
+ return typeof command === 'string' ? command : undefined;
49
+ }
50
+ /**
51
+ * The structured `subject` for a pending call: a `command` subject when the gate stopped a shell
52
+ * command (so the client can show the command as a command), a `tool_call` subject otherwise.
53
+ *
54
+ * `toolCallId` correlates the request with the tool call the client is ALREADY rendering from the
55
+ * `session/update` stream — the model emits its tool call before the graph suspends on the gate,
56
+ * so by the time a permission request goes out the client has drawn the call and is waiting on it.
57
+ * Without the correlation the prompt is about a tool call the client cannot point at.
58
+ */
59
+ function subjectFor(pending, toolCallId, cwd) {
60
+ const command = shellCommandOf(pending);
61
+ if (command !== undefined) {
62
+ return {
63
+ type: 'command',
64
+ command,
65
+ cwd,
66
+ ...(toolCallId === undefined ? {} : { toolCallId }),
67
+ };
68
+ }
69
+ return {
70
+ type: 'tool_call',
71
+ toolCall: {
72
+ // A `tool_call` subject has nowhere to put "no id", so a call the update stream never
73
+ // announced still needs one. It is minted rather than omitted, and it is the same shape a
74
+ // creating `tool_call_update` would carry, so a client that renders the subject shows the
75
+ // tool by name instead of an empty row.
76
+ toolCallId: toolCallId ?? `permission-${pending.name}`,
77
+ name: pending.name,
78
+ title: pending.name,
79
+ kind: toolKindFor(pending.name),
80
+ status: 'pending',
81
+ rawInput: pending.args,
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * The human-readable explanation for the prompt: everything the gate knows about WHY this call
87
+ * reached a human.
88
+ *
89
+ * Assembled from the same three sources the terminal surfaces show — the rater's verdict, the
90
+ * declared `approvals.escalate` entry that fired, and what a remembered answer would store — for
91
+ * the same reason: a user asked to rule on a call their configuration should have approved reads
92
+ * an unexplained prompt as the gate malfunctioning. Empty when the gate has nothing to add, in
93
+ * which case the field is omitted rather than sent as an empty string.
94
+ */
95
+ function descriptionFor(pending) {
96
+ const parts = [];
97
+ if (pending.safetyVerdict) {
98
+ parts.push(`AI rater: ${pending.safetyVerdict.outcome} — ${pending.safetyVerdict.reason}`.trimEnd());
99
+ }
100
+ if (pending.escalatedBy) {
101
+ parts.push(`Your approvals.escalate list matched this call: ${pending.escalatedBy}`);
102
+ }
103
+ if (pending.grantPreview) {
104
+ parts.push(`"Allow and remember" will store: ${pending.grantPreview}`);
105
+ }
106
+ return parts.length > 0 ? parts.join('\n\n') : undefined;
107
+ }
108
+ /** The ACP permission request for one pending tool call the gate escalated to a human. */
109
+ export function permissionRequestFor(options) {
110
+ const { sessionId, pending, toolCallId, cwd } = options;
111
+ const command = shellCommandOf(pending);
112
+ const description = descriptionFor(pending);
113
+ return {
114
+ sessionId,
115
+ // The title is the AGENT's own words and carries no untrusted text: what the model wrote is in
116
+ // the subject, where a client can style it as the model's.
117
+ title: command !== undefined ? 'Run a shell command' : `Run the ${pending.name} tool`,
118
+ ...(description === undefined ? {} : { description }),
119
+ subject: subjectFor(pending, toolCallId, cwd),
120
+ options: [...ACP_PERMISSION_OPTIONS],
121
+ };
122
+ }
123
+ /**
124
+ * The gate decision an ACP permission outcome means.
125
+ *
126
+ * **Every path that is not an explicit allow is a reject**, including an option id we do not
127
+ * recognise and an outcome variant that postdates this code. That polarity is the whole safety
128
+ * property: a client that answers with something unexpected must not be able to run a command,
129
+ * and a future ACP outcome must fail toward refusing rather than toward executing.
130
+ *
131
+ * `allow-always` maps to the `always` scope, which persists to the project allow-list — the same
132
+ * thing the terminal menu's *always approve* does. `reject-always` maps to the `session` scope
133
+ * because that is the strongest refusal the gate actually persists; ACP's `reject_always` is a UI
134
+ * hint, and promising a persistence nothing implements would be worse than serving it with the
135
+ * refusal that does exist. The option's own label says "for this session" so the user is told.
136
+ */
137
+ export function decisionForOutcome(outcome) {
138
+ if (outcome.outcome !== 'selected') {
139
+ return { type: 'reject', message: 'The client cancelled the permission request.' };
140
+ }
141
+ const optionId = outcome.optionId;
142
+ switch (optionId) {
143
+ case 'allow-once':
144
+ return { type: 'approve' };
145
+ case 'allow-always':
146
+ return { type: 'approve', scope: 'always' };
147
+ case 'reject-always':
148
+ return { type: 'reject', scope: 'session', message: 'The user rejected this tool call.' };
149
+ case 'reject-once':
150
+ return { type: 'reject', message: 'The user rejected this tool call.' };
151
+ default:
152
+ return {
153
+ type: 'reject',
154
+ message: `The client selected an unknown permission option (${String(optionId)}).`,
155
+ };
156
+ }
157
+ }
158
+ //# sourceMappingURL=acpPermissions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"acpPermissions.js","sourceRoot":"","sources":["../../../src/modules/acp/acpPermissions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * The stdio entry point both ACP doors go through — the standalone `gaunt-sloth-acp` bin and
4
+ * `gaunt-sloth --acp-agent`.
5
+ *
6
+ * **It is one function on purpose.** Two entry points spelling the same startup twice is how the
7
+ * doors drift, and the thing that would drift here is not cosmetic: it is the stdout guarantee
8
+ * below, which is invisible until an editor cannot parse a frame.
9
+ *
10
+ * ## stdout belongs to the protocol, and nothing else may touch it
11
+ *
12
+ * An ACP host reads JSON-RPC off the agent's stdout. Any other byte there is a framing error, not
13
+ * a message someone reads — and this codebase writes to stdout constantly: `displayInfo` and
14
+ * friends go through `console.log`/`console.info`, the streaming path writes to
15
+ * `process.stdout` directly, and the dev/custom toolkits pipe a child process's stdout through.
16
+ * A single status line would corrupt the stream.
17
+ *
18
+ * So the transport captures the REAL stdout writer first, and then `process.stdout.write` is
19
+ * redirected to stderr for the life of the process. Everything that thought it was printing to the
20
+ * user keeps working and lands on stderr, where a host shows it as agent output; the protocol
21
+ * keeps the channel it needs. Redirecting rather than silencing is deliberate — a warning nobody
22
+ * can see is how a misconfigured agent looks identical to a working one.
23
+ */
24
+ import type { AcpAgentAppOptions } from '#src/modules/acp/acpAgentApp.js';
25
+ /** Seams for the tests; production passes nothing. */
26
+ export interface AcpStdioOptions extends AcpAgentAppOptions {
27
+ /** Byte stream the client writes to. Defaults to the process's stdin. */
28
+ input?: ReadableStream<Uint8Array>;
29
+ /**
30
+ * Byte sink the protocol writes to. Defaults to the process's real stdout, captured before the
31
+ * redirect below so the redirect cannot swallow the protocol along with everything else.
32
+ */
33
+ output?: WritableStream<Uint8Array>;
34
+ }
35
+ /**
36
+ * Serves ACP v2 over stdio until the client disconnects.
37
+ *
38
+ * Resolves when the connection closes, so a bin can `await` it and exit cleanly rather than
39
+ * holding the event loop open on a socket nobody is reading.
40
+ */
41
+ export declare function startAcpServer(options?: AcpStdioOptions): Promise<void>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * The stdio entry point both ACP doors go through — the standalone `gaunt-sloth-acp` bin and
4
+ * `gaunt-sloth --acp-agent`.
5
+ *
6
+ * **It is one function on purpose.** Two entry points spelling the same startup twice is how the
7
+ * doors drift, and the thing that would drift here is not cosmetic: it is the stdout guarantee
8
+ * below, which is invisible until an editor cannot parse a frame.
9
+ *
10
+ * ## stdout belongs to the protocol, and nothing else may touch it
11
+ *
12
+ * An ACP host reads JSON-RPC off the agent's stdout. Any other byte there is a framing error, not
13
+ * a message someone reads — and this codebase writes to stdout constantly: `displayInfo` and
14
+ * friends go through `console.log`/`console.info`, the streaming path writes to
15
+ * `process.stdout` directly, and the dev/custom toolkits pipe a child process's stdout through.
16
+ * A single status line would corrupt the stream.
17
+ *
18
+ * So the transport captures the REAL stdout writer first, and then `process.stdout.write` is
19
+ * redirected to stderr for the life of the process. Everything that thought it was printing to the
20
+ * user keeps working and lands on stderr, where a host shows it as agent output; the protocol
21
+ * keeps the channel it needs. Redirecting rather than silencing is deliberate — a warning nobody
22
+ * can see is how a misconfigured agent looks identical to a working one.
23
+ */
24
+ import { Readable } from 'node:stream';
25
+ import * as acp from '@agentclientprotocol/sdk/experimental/v2';
26
+ import { announceAcpStart, createAcpAgentApp } from '#src/modules/acp/acpAgentApp.js';
27
+ /**
28
+ * Sends everything written to `process.stdout` to stderr instead, and returns a sink holding the
29
+ * ORIGINAL writer.
30
+ *
31
+ * The capture must happen before the redirect, and the sink must hold the captured function rather
32
+ * than reach for `process.stdout.write` at write time — otherwise the transport's own frames go
33
+ * through the redirect too and the agent talks to itself.
34
+ */
35
+ function captureStdoutForProtocol() {
36
+ const writeToRealStdout = process.stdout.write.bind(process.stdout);
37
+ const writeToStderr = process.stderr.write.bind(process.stderr);
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ process.stdout.write = ((chunk, ...rest) =>
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ writeToStderr(chunk, ...rest));
42
+ return new WritableStream({
43
+ write(chunk) {
44
+ writeToRealStdout(chunk);
45
+ },
46
+ });
47
+ }
48
+ /**
49
+ * Serves ACP v2 over stdio until the client disconnects.
50
+ *
51
+ * Resolves when the connection closes, so a bin can `await` it and exit cleanly rather than
52
+ * holding the event loop open on a socket nobody is reading.
53
+ */
54
+ export async function startAcpServer(options = {}) {
55
+ const { input, output, ...appOptions } = options;
56
+ // Before anything can print. `announceAcpStart` below is the first thing that tries, and it is
57
+ // there so the guarantee is exercised on every real start rather than only under test.
58
+ const protocolOut = output ?? captureStdoutForProtocol();
59
+ const protocolIn = input ?? Readable.toWeb(process.stdin);
60
+ const app = createAcpAgentApp(appOptions);
61
+ const connection = app.connect(acp.ndJsonStream(protocolOut, protocolIn));
62
+ announceAcpStart();
63
+ await connection.closed;
64
+ }
65
+ //# sourceMappingURL=acpStdio.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"acpStdio.js","sourceRoot":"","sources":["../../../src/modules/acp/acpStdio.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,KAAK,GAAG,MAAM,0CAA0C,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AActF;;;;;;;GAOG;AACH,SAAS,wBAAwB;IAC/B,MAAM,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACpE,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,8DAA8D;IAC9D,OAAO,CAAC,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,KAAU,EAAE,GAAG,IAAW,EAAE,EAAE;IACrD,8DAA8D;IAC7D,aAAqB,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,CAAgC,CAAC;IACzE,OAAO,IAAI,cAAc,CAAa;QACpC,KAAK,CAAC,KAAK;YACT,iBAAiB,CAAC,KAAK,CAAC,CAAC;QAC3B,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,OAAO,GAAoB,EAAE;IAChE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,UAAU,EAAE,GAAG,OAAO,CAAC;IACjD,+FAA+F;IAC/F,uFAAuF;IACvF,MAAM,WAAW,GAAG,MAAM,IAAI,wBAAwB,EAAE,CAAC;IACzD,MAAM,UAAU,GAAG,KAAK,IAAK,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAgC,CAAC;IAE1F,MAAM,GAAG,GAAG,iBAAiB,CAAC,UAAU,CAAC,CAAC;IAC1C,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC,CAAC;IAC1E,gBAAgB,EAAE,CAAC;IACnB,MAAM,UAAU,CAAC,MAAM,CAAC;AAC1B,CAAC"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Translates the agent runtime's typed {@link AgentStreamEvent} stream into ACP v2
4
+ * `session/update` payloads.
5
+ *
6
+ * Kept as a pure, stateful-but-transport-free mapper rather than inlined in the request handlers
7
+ * for two reasons. It is the half of the ACP surface with real logic — message identity, tool-call
8
+ * upserts, which events open and close a run — so it is the half worth testing without a
9
+ * connection. And the `session/update` **upsert semantics** live here and nowhere else: what the
10
+ * mapper omits is what a client must leave unchanged, so a mapper that re-sent a full replacement
11
+ * on every update would silently erase fields a client had already rendered.
12
+ *
13
+ * ## The upsert contract this mapper is written against
14
+ *
15
+ * A client applies updates per id, in arrival order: an omitted field leaves the stored value
16
+ * unchanged, `null` clears it, a concrete value replaces it, and a chunk appends. The first
17
+ * `tool_call_update` a client sees for a `toolCallId` CREATES the tool call. So the mapper sends
18
+ * the descriptive fields **once**, on the creating update, and every later update for that call
19
+ * carries only what actually changed — which is what makes a client's rendering of a running tool
20
+ * call correct rather than flickering back to a bare id.
21
+ */
22
+ import type { SessionUpdate, ToolKind } from '@agentclientprotocol/sdk/experimental/v2';
23
+ import type { AgentStreamEvent } from '@gaunt-sloth/core/core/types.js';
24
+ /** The ACP tool kind for a gth tool name; `other` for anything not built in. */
25
+ export declare function toolKindFor(name: string): ToolKind;
26
+ /**
27
+ * Turns one agent run's event stream into ACP `session/update` payloads.
28
+ *
29
+ * One instance per prompt turn: it holds the message identity of the assistant text run and of the
30
+ * reasoning run, plus the accumulated argument text per tool call. A fresh instance per turn is
31
+ * what makes a new turn a new `messageId`, which is how a client tells two messages apart.
32
+ */
33
+ export declare class AcpUpdateMapper {
34
+ /**
35
+ * `messageId` of the assistant text message currently being streamed, or `null` when no text run
36
+ * is open. Cleared whenever something else interrupts the text (a tool call, a reasoning block),
37
+ * so the text that resumes afterwards is a NEW message rather than an append to the one the
38
+ * client already considers finished.
39
+ */
40
+ private assistantMessageId;
41
+ /** `messageId` of the reasoning message currently being streamed, or `null` outside one. */
42
+ private thoughtMessageId;
43
+ /**
44
+ * Streamed argument text per tool call id, reassembled from `tool_args` deltas. Held rather than
45
+ * forwarded per delta because ACP has no argument-delta update: `rawInput` is a whole value, so
46
+ * it can only be sent once the deltas stop arriving.
47
+ */
48
+ private readonly toolArgs;
49
+ /**
50
+ * Tool calls the model has requested but which have neither produced a result nor been claimed by
51
+ * a permission request, in the order they were announced, as `[toolCallId, toolName]`.
52
+ *
53
+ * Kept so the approval bridge can name the tool call a permission request is ABOUT. The gate's
54
+ * `PendingToolInterrupt` carries the tool's name and arguments but no call id — the graph
55
+ * suspends inside the middleware wrapping the call, which is downstream of where the id lives —
56
+ * while the client has already drawn that call from this stream. This queue reconnects the two.
57
+ */
58
+ private readonly openToolCalls;
59
+ /**
60
+ * Takes the id of the unclaimed tool call that matches `name` and `args`, removing it from the
61
+ * queue, or `undefined` when there is none.
62
+ *
63
+ * **Matched on the ARGUMENTS, because position is not a reliable discriminator here.** The
64
+ * arguments are the one value both sides hold: the gate's `PendingToolInterrupt` carries them, and
65
+ * this mapper still holds the streamed argument text at claim time (it is only discarded on
66
+ * `tool_result`, which exists solely on the resumed run).
67
+ *
68
+ * **Where the exact match degrades, named rather than implied:** the mapper's side is JSON
69
+ * reassembled from the model's streamed argument deltas, and a local model that ignores
70
+ * `disable_parallel_tool_use` can merge sibling calls' buffers into invalid JSON (`{}{}`,
71
+ * `{"steps":3}{}` — see the AG-UI server's `parseToolArguments` note). {@link parseToolArgs}
72
+ * deliberately does not carry that path's recovery, so such a payload stays a raw string, matches
73
+ * nothing, and falls back to position — on exactly the model class most likely to emit sloppy
74
+ * parallel calls. The fallback is still the best remaining answer and the cost is which row a
75
+ * client attaches the prompt to, never which command the human rules on.
76
+ *
77
+ * **Position was tried and is wrong in both directions**, which is why it is only the fallback.
78
+ * The runner drains suspended calls as a BATCH — every pending call decided in turn before
79
+ * anything resumes — so a model emitting two parallel calls of one tool has both open and neither
80
+ * running. Returning "the most recent" handed BOTH requests the second call's id. But plain
81
+ * oldest-first is no better in the case that is *normal* at the rated rungs: this method is
82
+ * reached only from the human-approval callback, which sits behind the gate's earlier exits
83
+ * (not-gated, deny list, bypass, the hardline floor, the allow list, and the rater's own arms), so
84
+ * a batch where one call is settled without a human and its sibling escalates leaves the settled
85
+ * call's id unclaimed at the head of the queue — and oldest-first then hands the request the
86
+ * wrong one, with every upstream ordering assumption perfectly intact.
87
+ *
88
+ * **Consuming is right either way**, and the queue order still decides between two calls whose
89
+ * arguments are genuinely identical, where either answer is equally true.
90
+ *
91
+ * Absent rather than guessed when nothing matches — a permission request pointing at the WRONG
92
+ * call is worse than one pointing at no call, because a client would then attach the answer to a
93
+ * call the user never saw.
94
+ */
95
+ claimToolCallId(name: string, args?: Record<string, unknown>): string | undefined;
96
+ /**
97
+ * The `session/update` payloads one runtime event produces — usually one, sometimes none
98
+ * (`tool_args`, which only accumulates; the reasoning boundaries, which only move state).
99
+ */
100
+ map(event: AgentStreamEvent): SessionUpdate[];
101
+ }