@agentsky/acp 0.1.0 → 0.2.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/README.md +62 -30
- package/dist/index.js +699 -80
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
# @agentsky/acp
|
|
2
2
|
|
|
3
3
|
`agentsky-acp` runs an [AgentSky](https://agentsky.dev) cloud agent as a local
|
|
4
|
-
**ACP agent** (Agent Client Protocol, JSON-RPC over stdio). Any ACP client can
|
|
5
|
-
spawn it in place of a local harness; it proxies every turn to your
|
|
6
|
-
|
|
7
|
-
[
|
|
4
|
+
**ACP agent** (Agent Client Protocol v1, JSON-RPC over stdio). Any ACP client can
|
|
5
|
+
spawn it in place of a local harness; it proxies every turn to your AgentSky
|
|
6
|
+
session over the public `/api/v1` API. Built for
|
|
7
|
+
[Kylon Custom (ACP)](https://docs.kylon.io/agents/acp-agent-contract),
|
|
8
|
+
[block/buzz](https://github.com/block/buzz)'s `buzz-acp` harness, Zed, and any
|
|
9
|
+
other ACP v1 client.
|
|
8
10
|
|
|
9
|
-
Why:
|
|
11
|
+
Why: local ACP harnesses die with the laptop and forget on every session
|
|
10
12
|
rotation. An AgentSky agent is a persistent cloud pod — the bridge maps every
|
|
11
|
-
ACP session
|
|
12
|
-
|
|
13
|
+
ACP session onto a durable cloud conversation, so your agent stays online and
|
|
14
|
+
never loses memory.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
relay ◄─WS─ buzz-acp (ACP client) ─ACP/stdio→ agentsky-acp ─HTTPS→ agentsky.dev/api/v1
|
|
16
|
-
└─ posts replies via `buzz messages send`
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Setup
|
|
16
|
+
## Kylon setup
|
|
20
17
|
|
|
21
18
|
```sh
|
|
22
19
|
npm install -g @agentsky/acp
|
|
23
20
|
# get a token: https://agentsky.dev/settings?tab=tokens (or `sky auth login`)
|
|
24
21
|
|
|
22
|
+
kylon agent configure <agent-id> --env SKY_API_TOKEN=ast_… -- agentsky-acp @handle
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The agent defaults to `sky` (the Sky orchestrator). Pass any handle or
|
|
26
|
+
`sess-<id>` to target a specific agent.
|
|
27
|
+
|
|
28
|
+
## Buzz setup
|
|
29
|
+
|
|
30
|
+
```sh
|
|
25
31
|
export SKY_API_TOKEN=ast_…
|
|
26
32
|
export BUZZ_ACP_AGENT_COMMAND=agentsky-acp
|
|
27
33
|
export BUZZ_ACP_AGENT_ARGS=<your-agent-slug>
|
|
@@ -44,28 +50,54 @@ Or as a buzz desktop custom harness (`<app-data>/custom_harnesses/agentsky.json`
|
|
|
44
50
|
| env | meaning |
|
|
45
51
|
|---|---|
|
|
46
52
|
| `SKY_API_TOKEN` | AgentSky API token (`ast_…`), required |
|
|
47
|
-
| `SKY_API_URL` | API base, default `https://agentsky.dev` |
|
|
53
|
+
| `SKY_API_URL` | API base, default `https://api.agentsky.dev/v1` |
|
|
48
54
|
| `SKY_AGENT` | agent spec slug or `sess-…` session id (alternative to argv) |
|
|
49
55
|
| `SKY_UNIVERSE` | universe slug (sent as `X-Universe`) |
|
|
50
56
|
| `SKY_ACP_NO_REPLY_POST=1` | don't post replies to buzz channels via the buzz CLI |
|
|
51
57
|
| `SKY_ACP_BUZZ_BIN` | buzz CLI binary, default `buzz` |
|
|
52
58
|
| `SKY_ACP_TURN_TIMEOUT_MS` | per-turn safety timeout, default 3600000 |
|
|
59
|
+
| `SKY_ACP_REQUEST_TIMEOUT_MS` | how long to wait for a permission answer before applying the D5 fallback, default 120000 |
|
|
60
|
+
| `SKY_ACP_MCP_BRIDGE=0` | don't forward stdio MCP servers from `session/new` (they are forwarded through the relay bridge by default) |
|
|
61
|
+
|
|
62
|
+
## CLI flags
|
|
63
|
+
|
|
64
|
+
| flag | meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `--session per-acp` | one AgentSky session per ACP session (default outside buzz) |
|
|
67
|
+
| `--session agent` | all ACP sessions share one AgentSky session (default under buzz) |
|
|
68
|
+
| `--context-block none` | send all prompt text blocks as `user.message` instead of treating the first block as `system.message` |
|
|
69
|
+
| `--budget-cents <n>` | root budget cap in US cents |
|
|
53
70
|
|
|
54
71
|
## Behavior notes
|
|
55
72
|
|
|
56
|
-
- **
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
- **
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
- **Protocol v1**: accepts `initialize` with `protocolVersion: 1` only; returns
|
|
74
|
+
`-32000` for any other version or a missing `SKY_API_TOKEN`.
|
|
75
|
+
- **Session modes**:
|
|
76
|
+
- `per-acp` (default outside buzz): one AgentSky session per ACP session,
|
|
77
|
+
created lazily on the first prompt so the handshake stays inside 60 s.
|
|
78
|
+
- `agent` (default under buzz, detected via `BUZZ_RELAY_URL`): all ACP sessions
|
|
79
|
+
share one durable AgentSky session — channel context arrives in every prompt
|
|
80
|
+
and the agent keeps one continuous memory.
|
|
81
|
+
- **Context block** (item 3): the first text block of the first prompt is sent as
|
|
82
|
+
a `system.message` event right after the turn's `user.message`, in the same post (operating context per RFC-0149 §5 D6); subsequent
|
|
83
|
+
blocks are `user.message`. `--context-block none` disables this.
|
|
84
|
+
- **MCP server forwarding** (item 2): HTTP entries in `session/new`.`mcpServers`
|
|
85
|
+
are forwarded as a `mcp_servers` overlay on the created AgentSky session. Stdio entries
|
|
86
|
+
(Kylon's `kylon` server among them) are spawned locally, registered as a
|
|
87
|
+
bridge server via `POST /api/v1/bridges` and `POST /api/v1/bridges/{id}/servers`,
|
|
88
|
+
and the relay long-poll (`GET /api/v1/desktop/requests?wait=25`) proxies
|
|
89
|
+
MCP JSON-RPC to the local process. `SKY_ACP_MCP_BRIDGE=0` turns the stdio
|
|
90
|
+
bridge off. Both degrade to a log note when the platform answers 404.
|
|
91
|
+
- **Task request forwarding** (item 4): open task requests are forwarded to the
|
|
92
|
+
ACP client as `session/request_permission`; if the client doesn't answer within
|
|
93
|
+
`SKY_ACP_REQUEST_TIMEOUT_MS` or declines, the RFC-0149 D5 fallback is applied
|
|
94
|
+
(`approval`/`spend` → decline; `question` → best-judgement instruction).
|
|
95
|
+
- **Cancel**: `session/cancel` interrupts the running turn and settles with
|
|
96
|
+
`stopReason: "cancelled"` within 5 s.
|
|
97
|
+
- **loadSession**: advertised `true` in `agent` mode (session is always resumable);
|
|
98
|
+
`false` in `per-acp` mode.
|
|
99
|
+
- **Reply posting** (buzz only): the adapter posts the completed reply to the
|
|
100
|
+
buzz channel with `buzz messages send --channel <uuid>` (channel UUID parsed
|
|
101
|
+
from the prompt's `[Context]` section).
|
|
102
|
+
- **Reliability**: `Idempotency-Key` on every message post, standing SSE with
|
|
103
|
+
liveness watchdog + reconnect, events-ledger catch-up deduped by event id.
|
package/dist/index.js
CHANGED
|
@@ -16,9 +16,10 @@ var SkyApi = class {
|
|
|
16
16
|
}
|
|
17
17
|
config;
|
|
18
18
|
headers() {
|
|
19
|
-
const headers = {
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const headers = {};
|
|
20
|
+
if (this.config.token) {
|
|
21
|
+
headers["authorization"] = `Bearer ${this.config.token}`;
|
|
22
|
+
}
|
|
22
23
|
if (this.config.universe) headers["x-universe"] = this.config.universe;
|
|
23
24
|
return headers;
|
|
24
25
|
}
|
|
@@ -45,7 +46,8 @@ var SkyApi = class {
|
|
|
45
46
|
}
|
|
46
47
|
throw new ApiError(res.status, code, message);
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
return text ? JSON.parse(text) : {};
|
|
49
51
|
}
|
|
50
52
|
/** Resolve the agent ref to the one session this adapter drives: an
|
|
51
53
|
* explicit `sess-…` id, else the spec's default session, else a freshly
|
|
@@ -53,38 +55,86 @@ var SkyApi = class {
|
|
|
53
55
|
async resolveSession() {
|
|
54
56
|
const ref = this.config.agent;
|
|
55
57
|
if (ref.startsWith("sess-")) return ref;
|
|
56
|
-
const { agent } = await this.request("GET", `/
|
|
58
|
+
const { agent } = await this.request("GET", `/agents/${encodeURIComponent(ref)}`);
|
|
57
59
|
if (agent.defaultSessionId) return agent.defaultSessionId;
|
|
58
60
|
const { session } = await this.request(
|
|
59
61
|
"POST",
|
|
60
|
-
"/
|
|
62
|
+
"/sessions",
|
|
61
63
|
{ agent: ref, title: "buzz (acp bridge)" }
|
|
62
64
|
);
|
|
63
65
|
return session.id;
|
|
64
66
|
}
|
|
67
|
+
/** Create a new session for per-acp mode with optional budget and mcp overlay. */
|
|
68
|
+
async createSession(options) {
|
|
69
|
+
const body = {
|
|
70
|
+
agent: this.config.agent,
|
|
71
|
+
title: options.title ?? "acp session",
|
|
72
|
+
// The first prompt boots the sandbox, after any bridged MCP servers
|
|
73
|
+
// have been added to the overlay (setMcpServers).
|
|
74
|
+
provision: "deferred"
|
|
75
|
+
};
|
|
76
|
+
if (options.budgetCents !== void 0) {
|
|
77
|
+
body["budget"] = {
|
|
78
|
+
type: "limit",
|
|
79
|
+
max_list_cost: { amount: String(options.budgetCents), currency: "USD" }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (options.mcpServers && options.mcpServers.length > 0) {
|
|
83
|
+
body["mcp_servers"] = options.mcpServers;
|
|
84
|
+
}
|
|
85
|
+
const { session } = await this.request("POST", "/sessions", body);
|
|
86
|
+
return {
|
|
87
|
+
id: session.id,
|
|
88
|
+
taskId: session.taskId ?? session.task_id ?? null,
|
|
89
|
+
canReopen: session.canReopen ?? false
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Replace the session's MCP server overlay (PATCH /sessions/{id}). */
|
|
93
|
+
async setMcpServers(sessionId, servers) {
|
|
94
|
+
await this.request("PATCH", `/sessions/${encodeURIComponent(sessionId)}`, { mcp_servers: servers });
|
|
95
|
+
}
|
|
65
96
|
async sendMessage(sessionId, text, idempotencyKey) {
|
|
66
97
|
await this.request(
|
|
67
98
|
"POST",
|
|
68
|
-
`/
|
|
69
|
-
{
|
|
99
|
+
`/sessions/${encodeURIComponent(sessionId)}/events`,
|
|
100
|
+
{ events: [{ type: "user.message", content: [{ type: "text", text }] }] },
|
|
101
|
+
{ "idempotency-key": idempotencyKey }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
/** Send a turn whose operating context (D6) rides as a system.message.
|
|
105
|
+
* The platform accepts a system.message only right after a user.message,
|
|
106
|
+
* so both go in one post, user first. */
|
|
107
|
+
async sendMessageWithContext(sessionId, text, context, idempotencyKey) {
|
|
108
|
+
await this.request(
|
|
109
|
+
"POST",
|
|
110
|
+
`/sessions/${encodeURIComponent(sessionId)}/events`,
|
|
111
|
+
{
|
|
112
|
+
events: [
|
|
113
|
+
{ type: "user.message", content: [{ type: "text", text }] },
|
|
114
|
+
{ type: "system.message", content: [{ type: "text", text: context }] }
|
|
115
|
+
]
|
|
116
|
+
},
|
|
70
117
|
{ "idempotency-key": idempotencyKey }
|
|
71
118
|
);
|
|
72
119
|
}
|
|
73
120
|
async interrupt(sessionId) {
|
|
74
|
-
await this.request("POST", `/
|
|
121
|
+
await this.request("POST", `/sessions/${encodeURIComponent(sessionId)}/interrupt`);
|
|
75
122
|
}
|
|
123
|
+
/** Catch-up over the ChatMessage-backed history: user.message /
|
|
124
|
+
* agent.message / session.status_idle events only — per-part events
|
|
125
|
+
* (agent.reasoning, agent.tool_use, …) are live-stream-only. */
|
|
76
126
|
async listEvents(sessionId, cursor, limit = 500) {
|
|
77
127
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
78
128
|
if (cursor !== null) params.set("cursor", cursor);
|
|
79
129
|
return this.request(
|
|
80
130
|
"GET",
|
|
81
|
-
`/
|
|
131
|
+
`/sessions/${encodeURIComponent(sessionId)}/events?${params}`
|
|
82
132
|
);
|
|
83
133
|
}
|
|
84
134
|
/** Open the standing SSE stream. Returns the body; caller parses/aborts. */
|
|
85
135
|
async openStream(sessionId, signal) {
|
|
86
136
|
const res = await fetch(
|
|
87
|
-
`${this.config.apiBase}/
|
|
137
|
+
`${this.config.apiBase}/sessions/${encodeURIComponent(sessionId)}/stream`,
|
|
88
138
|
{ headers: { ...this.headers(), accept: "text/event-stream" }, signal }
|
|
89
139
|
);
|
|
90
140
|
if (!res.ok) {
|
|
@@ -93,32 +143,156 @@ var SkyApi = class {
|
|
|
93
143
|
if (!res.body) throw new ApiError(502, "no_body", "stream returned no body");
|
|
94
144
|
return res.body;
|
|
95
145
|
}
|
|
146
|
+
/** Fetch open requests for a task from GET /tasks/{id}.
|
|
147
|
+
* The task id is the root session id; task.requests[] contains the open
|
|
148
|
+
* requests addressed to the caller. Returns [] on 404 (task not yet created). */
|
|
149
|
+
async listOpenRequests(taskId) {
|
|
150
|
+
try {
|
|
151
|
+
const data = await this.request("GET", `/tasks/${encodeURIComponent(taskId)}`);
|
|
152
|
+
return data.task.requests ?? [];
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error instanceof ApiError && error.status === 404) return [];
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async answerRequest(taskId, requestId, answer) {
|
|
159
|
+
await this.request(
|
|
160
|
+
"POST",
|
|
161
|
+
`/tasks/${encodeURIComponent(taskId)}/requests/${encodeURIComponent(requestId)}/answer`,
|
|
162
|
+
answer
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
/** POST /api/v1/bridges — returns 404 when lane B not yet landed.
|
|
166
|
+
* Real shape: {bridge: {id, name, …}, connect_token: "brc_…"} — connect_token
|
|
167
|
+
* is at the TOP level, same convention as Mac machines (bridge PR #3314). */
|
|
168
|
+
async createBridge(name) {
|
|
169
|
+
try {
|
|
170
|
+
const data = await this.request("POST", "/bridges", { name });
|
|
171
|
+
const token = data.connect_token ?? data.bridge.connect_token ?? "";
|
|
172
|
+
return { id: data.bridge.id, connectToken: token };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof ApiError && error.status === 404) return null;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** POST /api/v1/bridges/{id}/servers — returns null on 404.
|
|
179
|
+
* Requires the bridge connect token as bearer (not the workspace API key).
|
|
180
|
+
* session_id must be a public sess_… id.
|
|
181
|
+
* Real shape: {server: {id, bridge_id, name, mcp_url, created_at}} */
|
|
182
|
+
async registerBridgeServer(bridgeId, name, connectToken, sessionId) {
|
|
183
|
+
try {
|
|
184
|
+
const body = { name };
|
|
185
|
+
if (sessionId) body["session_id"] = sessionId;
|
|
186
|
+
const data = await this.request("POST", `/bridges/${encodeURIComponent(bridgeId)}/servers`, body, {
|
|
187
|
+
authorization: `Bearer ${connectToken}`
|
|
188
|
+
});
|
|
189
|
+
return { id: data.server.id, mcpUrl: data.server.mcp_url };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof ApiError && error.status === 404) return null;
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** GET /api/v1/desktop/requests?wait=25 — long-poll for relay requests. */
|
|
196
|
+
async longPollDesktopRequests(connectToken, signal) {
|
|
197
|
+
try {
|
|
198
|
+
const data = await this.request("GET", "/desktop/requests?wait=25", void 0, {
|
|
199
|
+
authorization: `Bearer ${connectToken}`,
|
|
200
|
+
"x-skip-universe": "1"
|
|
201
|
+
});
|
|
202
|
+
return (data.requests ?? []).map((r) => ({
|
|
203
|
+
id: r.id,
|
|
204
|
+
serverId: relayServerKey(r.mount_id),
|
|
205
|
+
method: r.method,
|
|
206
|
+
body: r.body
|
|
207
|
+
}));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (signal.aborted) return [];
|
|
210
|
+
if (error instanceof ApiError && (error.status === 404 || error.status === 408)) return [];
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Answer one relay request with the HTTP response the pod receives. */
|
|
215
|
+
async answerDesktopRequest(requestId, response, connectToken) {
|
|
216
|
+
await this.request(
|
|
217
|
+
"POST",
|
|
218
|
+
`/desktop/requests/${encodeURIComponent(requestId)}/response`,
|
|
219
|
+
{ status: response.status, headers: response.headers ?? {}, body: response.body ?? null },
|
|
220
|
+
{ authorization: `Bearer ${connectToken}` }
|
|
221
|
+
);
|
|
222
|
+
}
|
|
96
223
|
};
|
|
224
|
+
function relayServerKey(id) {
|
|
225
|
+
return id.replace(/^(dmt|brs)_/, "");
|
|
226
|
+
}
|
|
97
227
|
|
|
98
228
|
// src/agent.ts
|
|
99
|
-
import { randomUUID } from "crypto";
|
|
229
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
100
230
|
|
|
101
231
|
// src/buzz.ts
|
|
102
232
|
import { execFile } from "child_process";
|
|
103
233
|
|
|
104
234
|
// src/config.ts
|
|
235
|
+
function isBuzz() {
|
|
236
|
+
return Boolean(process.env.BUZZ_RELAY_URL || process.env.BUZZ_ACP_BIN);
|
|
237
|
+
}
|
|
238
|
+
function parseSessionMode(raw, defaultMode) {
|
|
239
|
+
if (raw === "per-acp" || raw === "agent") return raw;
|
|
240
|
+
if (raw !== void 0) {
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
`[agentsky-acp] unknown --session value "${raw}"; using "${defaultMode}"
|
|
243
|
+
`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return defaultMode;
|
|
247
|
+
}
|
|
105
248
|
function loadConfig(argv) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
249
|
+
const args = [...argv];
|
|
250
|
+
let sessionModeArg;
|
|
251
|
+
let contextBlockArg = true;
|
|
252
|
+
let budgetCents;
|
|
253
|
+
const positional = [];
|
|
254
|
+
for (let i = 0; i < args.length; i++) {
|
|
255
|
+
const a = args[i];
|
|
256
|
+
if (a === "--session") {
|
|
257
|
+
sessionModeArg = args[++i];
|
|
258
|
+
} else if (a.startsWith("--session=")) {
|
|
259
|
+
sessionModeArg = a.slice("--session=".length);
|
|
260
|
+
} else if (a === "--context-block") {
|
|
261
|
+
const v = args[++i];
|
|
262
|
+
if (v === "none") contextBlockArg = false;
|
|
263
|
+
} else if (a.startsWith("--context-block=")) {
|
|
264
|
+
contextBlockArg = a.slice("--context-block=".length) !== "none";
|
|
265
|
+
} else if (a === "--budget-cents") {
|
|
266
|
+
budgetCents = Number(args[++i]);
|
|
267
|
+
} else if (a.startsWith("--budget-cents=")) {
|
|
268
|
+
budgetCents = Number(a.slice("--budget-cents=".length));
|
|
269
|
+
} else {
|
|
270
|
+
positional.push(a);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const agent = positional[0] ?? process.env.SKY_AGENT ?? "sky";
|
|
274
|
+
const token = process.env.SKY_API_TOKEN || void 0;
|
|
275
|
+
if (!agent) {
|
|
109
276
|
process.stderr.write(
|
|
110
|
-
"usage: SKY_API_TOKEN=ast_\u2026 agentsky-acp <agent-
|
|
277
|
+
"usage: SKY_API_TOKEN=ast_\u2026 agentsky-acp [--session per-acp|agent] [--context-block none] [--budget-cents N] <agent-id>\n env: SKY_API_URL (default https://api.agentsky.dev/v1), SKY_AGENT (alternative to argv),\n SKY_UNIVERSE, SKY_ACP_NO_REPLY_POST=1, SKY_ACP_BUZZ_BIN (default buzz),\n SKY_ACP_TURN_TIMEOUT_MS (default 3600000), SKY_ACP_REQUEST_TIMEOUT_MS (default 120000),\n SKY_ACP_MCP_BRIDGE=0 (disable the stdio MCP bridge; on by default)\n"
|
|
111
278
|
);
|
|
112
279
|
process.exit(2);
|
|
113
280
|
}
|
|
281
|
+
const defaultMode = isBuzz() ? "agent" : "per-acp";
|
|
282
|
+
const sessionMode = parseSessionMode(sessionModeArg, defaultMode);
|
|
114
283
|
return {
|
|
115
|
-
apiBase: (process.env.SKY_API_URL ?? "https://agentsky.dev").replace(/\/$/, ""),
|
|
284
|
+
apiBase: (process.env.SKY_API_URL ?? "https://api.agentsky.dev/v1").replace(/\/$/, ""),
|
|
116
285
|
token,
|
|
117
286
|
agent,
|
|
118
287
|
universe: process.env.SKY_UNIVERSE,
|
|
119
288
|
replyPost: process.env.SKY_ACP_NO_REPLY_POST !== "1",
|
|
120
289
|
buzzBin: process.env.SKY_ACP_BUZZ_BIN ?? "buzz",
|
|
121
|
-
turnTimeoutMs: Number(process.env.SKY_ACP_TURN_TIMEOUT_MS ?? 36e5)
|
|
290
|
+
turnTimeoutMs: Number(process.env.SKY_ACP_TURN_TIMEOUT_MS ?? 36e5),
|
|
291
|
+
sessionMode,
|
|
292
|
+
contextBlock: contextBlockArg,
|
|
293
|
+
budgetCents,
|
|
294
|
+
requestTimeoutMs: Number(process.env.SKY_ACP_REQUEST_TIMEOUT_MS ?? 12e4),
|
|
295
|
+
mcpBridge: process.env.SKY_ACP_MCP_BRIDGE !== "0"
|
|
122
296
|
};
|
|
123
297
|
}
|
|
124
298
|
function log(message) {
|
|
@@ -279,20 +453,38 @@ var RpcEndpoint = class {
|
|
|
279
453
|
input;
|
|
280
454
|
output;
|
|
281
455
|
handlers = /* @__PURE__ */ new Map();
|
|
456
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
457
|
+
nextId = 1;
|
|
282
458
|
on(method, handler) {
|
|
283
459
|
this.handlers.set(method, handler);
|
|
284
460
|
}
|
|
285
461
|
notify(method, params) {
|
|
286
462
|
this.write({ jsonrpc: "2.0", method, params });
|
|
287
463
|
}
|
|
464
|
+
/** Send a JSON-RPC request to the client and wait for a response. */
|
|
465
|
+
async request(method, params, timeoutMs) {
|
|
466
|
+
const id = this.nextId++;
|
|
467
|
+
return new Promise((resolve, reject) => {
|
|
468
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
469
|
+
this.write({ jsonrpc: "2.0", id, method, params });
|
|
470
|
+
if (timeoutMs !== void 0) {
|
|
471
|
+
setTimeout(() => {
|
|
472
|
+
if (this.pendingRequests.has(id)) {
|
|
473
|
+
this.pendingRequests.delete(id);
|
|
474
|
+
reject(new Error(`request ${method} timed out after ${timeoutMs}ms`));
|
|
475
|
+
}
|
|
476
|
+
}, timeoutMs);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
288
480
|
/** Consume stdin until EOF, dispatching requests and notifications. */
|
|
289
481
|
async run() {
|
|
290
482
|
const lines = createInterface({ input: this.input, crlfDelay: Infinity });
|
|
291
483
|
for await (const line of lines) {
|
|
292
484
|
if (!line.trim()) continue;
|
|
293
|
-
let
|
|
485
|
+
let frame;
|
|
294
486
|
try {
|
|
295
|
-
|
|
487
|
+
frame = JSON.parse(line);
|
|
296
488
|
} catch {
|
|
297
489
|
this.write({
|
|
298
490
|
jsonrpc: "2.0",
|
|
@@ -301,6 +493,20 @@ var RpcEndpoint = class {
|
|
|
301
493
|
});
|
|
302
494
|
continue;
|
|
303
495
|
}
|
|
496
|
+
if (!("method" in frame) && "id" in frame && frame.id !== null && frame.id !== void 0) {
|
|
497
|
+
const response = frame;
|
|
498
|
+
const pending = this.pendingRequests.get(response.id);
|
|
499
|
+
if (pending) {
|
|
500
|
+
this.pendingRequests.delete(response.id);
|
|
501
|
+
if (response.error) {
|
|
502
|
+
pending.reject(new RpcError(response.error.code, response.error.message, response.error.data));
|
|
503
|
+
} else {
|
|
504
|
+
pending.resolve(response.result);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const request = frame;
|
|
304
510
|
void this.dispatch(request);
|
|
305
511
|
}
|
|
306
512
|
}
|
|
@@ -336,59 +542,333 @@ var RpcEndpoint = class {
|
|
|
336
542
|
}
|
|
337
543
|
};
|
|
338
544
|
|
|
545
|
+
// src/bridge.ts
|
|
546
|
+
import { spawn } from "child_process";
|
|
547
|
+
import { randomUUID } from "crypto";
|
|
548
|
+
var MAX_CLAIMS = 4;
|
|
549
|
+
var McpBridge = class {
|
|
550
|
+
constructor(api) {
|
|
551
|
+
this.api = api;
|
|
552
|
+
}
|
|
553
|
+
api;
|
|
554
|
+
servers = /* @__PURE__ */ new Map();
|
|
555
|
+
// name → live server
|
|
556
|
+
aborts = /* @__PURE__ */ new Map();
|
|
557
|
+
// serverId → relay abort
|
|
558
|
+
bridgeId = null;
|
|
559
|
+
connectToken = null;
|
|
560
|
+
/** Spawn and register all stdio entries. Returns mcp_urls keyed by name.
|
|
561
|
+
* HTTP entries are passed through unchanged. Failures are logged, not thrown. */
|
|
562
|
+
async setup(entries, sessionId) {
|
|
563
|
+
const result = /* @__PURE__ */ new Map();
|
|
564
|
+
if (!this.bridgeId) {
|
|
565
|
+
const bridge = await this.api.createBridge(`acp-${randomUUID().slice(0, 8)}`);
|
|
566
|
+
if (!bridge) {
|
|
567
|
+
log("mcp bridge endpoints not available (lane B not yet landed); stdio servers ignored");
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
this.bridgeId = bridge.id;
|
|
571
|
+
this.connectToken = bridge.connectToken;
|
|
572
|
+
}
|
|
573
|
+
for (const entry of entries) {
|
|
574
|
+
try {
|
|
575
|
+
const mcpUrl = await this.spawnAndRegister(entry, sessionId);
|
|
576
|
+
if (mcpUrl) result.set(entry.name, mcpUrl);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
log(
|
|
579
|
+
`failed to start mcp server "${entry.name}": ${error instanceof Error ? error.message : String(error)}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
async spawnAndRegister(entry, sessionId) {
|
|
586
|
+
const existing = this.servers.get(entry.name);
|
|
587
|
+
if (existing) {
|
|
588
|
+
log(`reusing existing bridge server "${entry.name}"`);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const child = spawn(entry.command, entry.args ?? [], {
|
|
592
|
+
env: { ...process.env, ...entry.env },
|
|
593
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
594
|
+
});
|
|
595
|
+
await new Promise((resolve, reject) => {
|
|
596
|
+
child.once("spawn", resolve);
|
|
597
|
+
child.once("error", reject);
|
|
598
|
+
setTimeout(() => reject(new Error("spawn timed out")), 5e3);
|
|
599
|
+
});
|
|
600
|
+
const server = await this.api.registerBridgeServer(
|
|
601
|
+
this.bridgeId,
|
|
602
|
+
entry.name,
|
|
603
|
+
this.connectToken,
|
|
604
|
+
sessionId
|
|
605
|
+
);
|
|
606
|
+
if (!server) {
|
|
607
|
+
child.kill();
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
const live = {
|
|
611
|
+
process: child,
|
|
612
|
+
serverId: server.id,
|
|
613
|
+
connectToken: this.connectToken
|
|
614
|
+
};
|
|
615
|
+
this.servers.set(entry.name, live);
|
|
616
|
+
log(`registered bridge server "${entry.name}" \u2192 ${server.mcpUrl}`);
|
|
617
|
+
const ac = new AbortController();
|
|
618
|
+
this.aborts.set(server.id, ac);
|
|
619
|
+
void this.relayLoop(live, ac.signal).catch(
|
|
620
|
+
(error) => log(`relay loop "${entry.name}" error: ${error instanceof Error ? error.message : String(error)}`)
|
|
621
|
+
);
|
|
622
|
+
return server.mcpUrl;
|
|
623
|
+
}
|
|
624
|
+
/** Long-poll loop: claim relay requests, proxy to stdio, answer back. */
|
|
625
|
+
async relayLoop(live, signal) {
|
|
626
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
627
|
+
while (!signal.aborted) {
|
|
628
|
+
let batch;
|
|
629
|
+
try {
|
|
630
|
+
batch = await this.api.longPollDesktopRequests(live.connectToken, signal);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (signal.aborted) break;
|
|
633
|
+
log(`relay poll error: ${error instanceof Error ? error.message : String(error)}`);
|
|
634
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
for (const item of batch) {
|
|
638
|
+
if (item.serverId !== relayServerKey(live.serverId)) continue;
|
|
639
|
+
if (inflight.size >= MAX_CLAIMS) continue;
|
|
640
|
+
if (inflight.has(item.id)) continue;
|
|
641
|
+
inflight.add(item.id);
|
|
642
|
+
void this.handleOne(item, live, signal).finally(
|
|
643
|
+
() => inflight.delete(item.id)
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async handleOne(item, live, signal) {
|
|
649
|
+
const json = { "content-type": "application/json" };
|
|
650
|
+
if (item.method !== "POST") {
|
|
651
|
+
await this.answer(item.id, { status: 405, headers: { allow: "POST" } }, live);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
let request = null;
|
|
655
|
+
try {
|
|
656
|
+
request = JSON.parse(item.body ?? "");
|
|
657
|
+
} catch {
|
|
658
|
+
await this.answer(item.id, { status: 400, headers: json, body: JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }) }, live);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (request && request.id === void 0) {
|
|
662
|
+
live.process.stdin?.write(`${JSON.stringify(request)}
|
|
663
|
+
`);
|
|
664
|
+
await this.answer(item.id, { status: 202 }, live);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const response = await this.proxyToStdio(live.process, request, signal);
|
|
669
|
+
if (!signal.aborted) await this.answer(item.id, { status: 200, headers: json, body: JSON.stringify(response) }, live);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (signal.aborted) return;
|
|
672
|
+
const body = JSON.stringify({
|
|
673
|
+
jsonrpc: "2.0",
|
|
674
|
+
id: request?.id ?? null,
|
|
675
|
+
error: { code: -32603, message: error instanceof Error ? error.message : String(error) }
|
|
676
|
+
});
|
|
677
|
+
await this.answer(item.id, { status: 200, headers: json, body }, live);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async answer(requestId, response, live) {
|
|
681
|
+
await this.api.answerDesktopRequest(requestId, response, live.connectToken).catch((e) => log(`answer relay error: ${e instanceof Error ? e.message : String(e)}`));
|
|
682
|
+
}
|
|
683
|
+
/** Send JSON-RPC to the stdio process and wait for the matching response. */
|
|
684
|
+
proxyToStdio(child, request, signal) {
|
|
685
|
+
return new Promise((resolve, reject) => {
|
|
686
|
+
const reqId = request?.id;
|
|
687
|
+
let buffered = "";
|
|
688
|
+
const onData = (chunk) => {
|
|
689
|
+
buffered += chunk.toString();
|
|
690
|
+
let nl;
|
|
691
|
+
while ((nl = buffered.indexOf("\n")) !== -1) {
|
|
692
|
+
const line2 = buffered.slice(0, nl).trim();
|
|
693
|
+
buffered = buffered.slice(nl + 1);
|
|
694
|
+
if (!line2) continue;
|
|
695
|
+
try {
|
|
696
|
+
const frame = JSON.parse(line2);
|
|
697
|
+
if (frame.id === reqId) {
|
|
698
|
+
cleanup();
|
|
699
|
+
resolve(frame);
|
|
700
|
+
}
|
|
701
|
+
} catch {
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
const onError = (err) => {
|
|
706
|
+
cleanup();
|
|
707
|
+
reject(err);
|
|
708
|
+
};
|
|
709
|
+
const onAbort = () => {
|
|
710
|
+
cleanup();
|
|
711
|
+
reject(new Error("aborted"));
|
|
712
|
+
};
|
|
713
|
+
const cleanup = () => {
|
|
714
|
+
child.stdout?.removeListener("data", onData);
|
|
715
|
+
child.removeListener("error", onError);
|
|
716
|
+
signal.removeEventListener("abort", onAbort);
|
|
717
|
+
};
|
|
718
|
+
child.stdout?.on("data", onData);
|
|
719
|
+
child.on("error", onError);
|
|
720
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
721
|
+
const line = JSON.stringify(request) + "\n";
|
|
722
|
+
child.stdin?.write(line, (err) => {
|
|
723
|
+
if (err) {
|
|
724
|
+
cleanup();
|
|
725
|
+
reject(err);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
stop() {
|
|
731
|
+
for (const ac of this.aborts.values()) ac.abort();
|
|
732
|
+
for (const live of this.servers.values()) {
|
|
733
|
+
live.process.kill();
|
|
734
|
+
}
|
|
735
|
+
this.servers.clear();
|
|
736
|
+
this.aborts.clear();
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
339
740
|
// src/agent.ts
|
|
340
|
-
var VERSION = "0.
|
|
341
|
-
var PROTOCOL_VERSION =
|
|
741
|
+
var VERSION = "0.2.0";
|
|
742
|
+
var PROTOCOL_VERSION = 1;
|
|
342
743
|
var BRIDGE_NOTE = "[Bridge note] You are connected to this Buzz workspace through the AgentSky bridge. The `buzz` CLI and Nostr relay are NOT available in your environment \u2014 do not attempt to run `buzz` commands. Write your reply as plain response text; the bridge posts it to the correct channel for you.";
|
|
343
744
|
var AcpAgent = class {
|
|
344
745
|
constructor(config, api, rpc) {
|
|
345
746
|
this.config = config;
|
|
346
747
|
this.api = api;
|
|
347
748
|
this.rpc = rpc;
|
|
749
|
+
this.bridge = new McpBridge(api);
|
|
348
750
|
}
|
|
349
751
|
config;
|
|
350
752
|
api;
|
|
351
753
|
rpc;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
754
|
+
// per-acp mode: ACP session id → AgentSky session state (populated lazily)
|
|
755
|
+
perAcpSessions = /* @__PURE__ */ new Map();
|
|
756
|
+
// per-acp mode: ACP session id → pending params from session/new
|
|
757
|
+
pendingNewParams = /* @__PURE__ */ new Map();
|
|
758
|
+
// agent mode: the single shared session
|
|
759
|
+
sharedState = null;
|
|
760
|
+
sharedInitPromise = null;
|
|
355
761
|
turnChain = Promise.resolve();
|
|
356
762
|
inflight = false;
|
|
763
|
+
interruptRequested = false;
|
|
357
764
|
acpSessionCounter = 0;
|
|
765
|
+
bridge;
|
|
358
766
|
register() {
|
|
359
|
-
this.rpc.on("initialize", () => (
|
|
767
|
+
this.rpc.on("initialize", (params) => this.initialize(params));
|
|
768
|
+
this.rpc.on("authenticate", () => ({}));
|
|
769
|
+
this.rpc.on("session/new", (params) => this.newSession(params));
|
|
770
|
+
this.rpc.on("session/prompt", (params) => this.prompt(params));
|
|
771
|
+
this.rpc.on("session/cancel", () => this.cancel());
|
|
772
|
+
}
|
|
773
|
+
initialize(params) {
|
|
774
|
+
const requested = params?.protocolVersion;
|
|
775
|
+
if (requested !== void 0 && requested !== PROTOCOL_VERSION) {
|
|
776
|
+
throw new RpcError(
|
|
777
|
+
-32602,
|
|
778
|
+
`unsupported protocol version ${requested}; this adapter supports version ${PROTOCOL_VERSION}`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (!this.config.token) {
|
|
782
|
+
throw new RpcError(-32e3, "SKY_API_TOKEN is not set; set it with --env SKY_API_TOKEN=ast_\u2026");
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
360
785
|
protocolVersion: PROTOCOL_VERSION,
|
|
361
786
|
agentCapabilities: {
|
|
362
|
-
loadSession:
|
|
787
|
+
loadSession: this.config.sessionMode === "agent",
|
|
363
788
|
promptCapabilities: { image: false, audio: false, embeddedContext: false }
|
|
364
789
|
},
|
|
365
790
|
authMethods: [],
|
|
366
791
|
agentInfo: { name: "agentsky-acp", version: VERSION }
|
|
367
|
-
}
|
|
368
|
-
this.rpc.on("authenticate", () => ({}));
|
|
369
|
-
this.rpc.on("session/new", () => this.newSession());
|
|
370
|
-
this.rpc.on("session/prompt", (params) => this.prompt(params));
|
|
371
|
-
this.rpc.on("session/cancel", () => this.cancel());
|
|
792
|
+
};
|
|
372
793
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
this.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
794
|
+
async newSession(params) {
|
|
795
|
+
const acpSessionId = `sky-acp-${++this.acpSessionCounter}-${randomUUID2().slice(0, 8)}`;
|
|
796
|
+
if (this.config.sessionMode === "agent") {
|
|
797
|
+
void this.ensureShared();
|
|
798
|
+
} else {
|
|
799
|
+
this.pendingNewParams.set(acpSessionId, params);
|
|
800
|
+
}
|
|
801
|
+
return { sessionId: acpSessionId };
|
|
802
|
+
}
|
|
803
|
+
// ── Session resolution ────────────────────────────────────────────────────
|
|
804
|
+
ensureShared() {
|
|
805
|
+
this.sharedInitPromise ??= this.createSharedState();
|
|
806
|
+
return this.sharedInitPromise;
|
|
807
|
+
}
|
|
808
|
+
async createSharedState() {
|
|
809
|
+
const sessionId = await this.api.resolveSession();
|
|
810
|
+
const bus = new EventBus(this.api, sessionId);
|
|
811
|
+
await bus.primeCursor().catch((error) => {
|
|
812
|
+
log(`cursor prime failed (continuing live-only): ${String(error)}`);
|
|
813
|
+
});
|
|
814
|
+
void bus.run();
|
|
815
|
+
this.sharedState = { skySessionId: sessionId, taskId: null, bus, canReopen: true };
|
|
816
|
+
log(`attached to shared session ${sessionId}`);
|
|
817
|
+
return this.sharedState;
|
|
818
|
+
}
|
|
819
|
+
async ensurePerAcp(acpSessionId) {
|
|
820
|
+
const existing = this.perAcpSessions.get(acpSessionId);
|
|
821
|
+
if (existing) return existing;
|
|
822
|
+
const params = this.pendingNewParams.get(acpSessionId) ?? {};
|
|
823
|
+
const httpOverlay = [];
|
|
824
|
+
const stdioEntries = [];
|
|
825
|
+
for (const srv of params.mcpServers ?? []) {
|
|
826
|
+
const isHttp = srv.type === "http" || srv.type === "sse" || srv.url && !srv.command;
|
|
827
|
+
if (isHttp && srv.url) {
|
|
828
|
+
httpOverlay.push({ name: srv.name, url: srv.url });
|
|
829
|
+
} else if (srv.command) {
|
|
830
|
+
stdioEntries.push({ name: srv.name, command: srv.command, args: srv.args, env: srv.env });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const { id: skySessionId, taskId, canReopen } = await this.api.createSession({
|
|
834
|
+
title: `acp-${acpSessionId}`,
|
|
835
|
+
budgetCents: this.config.budgetCents,
|
|
836
|
+
mcpServers: httpOverlay.length > 0 ? httpOverlay : void 0
|
|
837
|
+
});
|
|
838
|
+
if (this.config.mcpBridge && stdioEntries.length > 0) {
|
|
839
|
+
const urlMap = await this.bridge.setup(stdioEntries, skySessionId);
|
|
840
|
+
if (urlMap.size > 0) {
|
|
841
|
+
const bridged = [...urlMap].map(([name, url]) => ({ name, url }));
|
|
842
|
+
await this.api.setMcpServers(skySessionId, [...httpOverlay, ...bridged]);
|
|
843
|
+
}
|
|
844
|
+
if (urlMap.size < stdioEntries.length) {
|
|
845
|
+
const missing = stdioEntries.filter((e) => !urlMap.has(e.name)).map((e) => e.name).join(", ");
|
|
846
|
+
log(`stdio mcp server(s) not registered: ${missing}`);
|
|
847
|
+
}
|
|
848
|
+
} else if (!this.config.mcpBridge && stdioEntries.length > 0) {
|
|
849
|
+
log(
|
|
850
|
+
`stdio mcp servers ignored (SKY_ACP_MCP_BRIDGE=0): ${stdioEntries.map((e) => e.name).join(", ")}`
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
const bus = new EventBus(this.api, skySessionId);
|
|
854
|
+
await bus.primeCursor().catch((error) => {
|
|
855
|
+
log(`cursor prime failed (continuing live-only): ${String(error)}`);
|
|
856
|
+
});
|
|
857
|
+
void bus.run();
|
|
858
|
+
const state = { skySessionId, taskId, bus, canReopen };
|
|
859
|
+
this.perAcpSessions.set(acpSessionId, state);
|
|
860
|
+
this.pendingNewParams.delete(acpSessionId);
|
|
861
|
+
log(`created per-acp session ${skySessionId} for acp session ${acpSessionId}`);
|
|
862
|
+
return state;
|
|
387
863
|
}
|
|
388
|
-
async
|
|
389
|
-
|
|
390
|
-
|
|
864
|
+
async resolveState(acpSessionId) {
|
|
865
|
+
if (this.config.sessionMode === "agent") {
|
|
866
|
+
return this.ensureShared();
|
|
867
|
+
}
|
|
868
|
+
if (!acpSessionId) throw new RpcError(-32602, "sessionId required in per-acp mode");
|
|
869
|
+
return this.ensurePerAcp(acpSessionId);
|
|
391
870
|
}
|
|
871
|
+
// ── Turns ─────────────────────────────────────────────────────────────────
|
|
392
872
|
prompt(params) {
|
|
393
873
|
const turn = this.turnChain.then(
|
|
394
874
|
() => this.runTurn(params),
|
|
@@ -398,16 +878,29 @@ var AcpAgent = class {
|
|
|
398
878
|
return turn;
|
|
399
879
|
}
|
|
400
880
|
async runTurn(params) {
|
|
401
|
-
await this.
|
|
402
|
-
const sessionId =
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
881
|
+
const state = await this.resolveState(params.sessionId);
|
|
882
|
+
const { skySessionId: sessionId, bus, taskId } = state;
|
|
883
|
+
const blocks = params.prompt ?? [];
|
|
884
|
+
const textBlocks = blocks.filter(
|
|
885
|
+
(block) => block.type === "text" && typeof block.text === "string"
|
|
886
|
+
);
|
|
887
|
+
if (textBlocks.length === 0) return { stopReason: "end_turn" };
|
|
408
888
|
const acpSessionId = params.sessionId;
|
|
889
|
+
const channelId = extractChannelId(
|
|
890
|
+
textBlocks.map((b) => b.text).join("\n")
|
|
891
|
+
);
|
|
892
|
+
let systemText = null;
|
|
893
|
+
let userText;
|
|
894
|
+
if (this.config.contextBlock && textBlocks.length > 1) {
|
|
895
|
+
systemText = textBlocks[0].text;
|
|
896
|
+
userText = textBlocks.slice(1).map((b) => b.text).join("\n\n");
|
|
897
|
+
} else {
|
|
898
|
+
userText = textBlocks.map((b) => b.text).join("\n\n");
|
|
899
|
+
}
|
|
900
|
+
if (!userText.trim() && !systemText) return { stopReason: "end_turn" };
|
|
409
901
|
let replyText = "";
|
|
410
902
|
let sawTurnActivity = false;
|
|
903
|
+
this.interruptRequested = false;
|
|
411
904
|
let settle;
|
|
412
905
|
let fail;
|
|
413
906
|
const done = new Promise((resolve, reject) => {
|
|
@@ -416,10 +909,6 @@ var AcpAgent = class {
|
|
|
416
909
|
});
|
|
417
910
|
const unsubscribe = bus.onEvent((event) => {
|
|
418
911
|
switch (event.type) {
|
|
419
|
-
case "user.message":
|
|
420
|
-
case "turn.accepted":
|
|
421
|
-
sawTurnActivity = true;
|
|
422
|
-
break;
|
|
423
912
|
case "agent.message": {
|
|
424
913
|
sawTurnActivity = true;
|
|
425
914
|
const chunk = typeof event.text === "string" ? event.text : "";
|
|
@@ -448,7 +937,7 @@ var AcpAgent = class {
|
|
|
448
937
|
const part = event.part;
|
|
449
938
|
this.update(acpSessionId, {
|
|
450
939
|
sessionUpdate: "tool_call",
|
|
451
|
-
toolCallId: part?.call_id ??
|
|
940
|
+
toolCallId: part?.call_id ?? randomUUID2(),
|
|
452
941
|
title: part?.tool_name ?? "tool",
|
|
453
942
|
kind: "other",
|
|
454
943
|
status: "in_progress"
|
|
@@ -467,21 +956,18 @@ var AcpAgent = class {
|
|
|
467
956
|
}
|
|
468
957
|
break;
|
|
469
958
|
}
|
|
470
|
-
case "
|
|
471
|
-
if (sawTurnActivity) settle("end_turn");
|
|
959
|
+
case "session.status_idle":
|
|
960
|
+
if (sawTurnActivity) settle(this.interruptRequested ? "cancelled" : "end_turn");
|
|
472
961
|
break;
|
|
473
|
-
case "
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const code = typeof event.code === "string" ? event.code : "";
|
|
478
|
-
if (code === "enqueue_failed") {
|
|
479
|
-
fail(new Error("turn enqueue failed \u2014 pod unavailable"));
|
|
480
|
-
} else {
|
|
481
|
-
log(`stream error event: ${code} ${String(event.message ?? "")}`);
|
|
482
|
-
}
|
|
962
|
+
case "session.error": {
|
|
963
|
+
const error = event.error;
|
|
964
|
+
const retry = typeof error?.retry_status === "string" ? ` (${error.retry_status})` : "";
|
|
965
|
+
log(`session error: ${String(error?.type ?? "")} ${String(error?.message ?? "")}${retry}`);
|
|
483
966
|
break;
|
|
484
967
|
}
|
|
968
|
+
case "session.status_terminated":
|
|
969
|
+
fail(new Error("session terminated"));
|
|
970
|
+
break;
|
|
485
971
|
case "session.deleted":
|
|
486
972
|
fail(new Error("session deleted"));
|
|
487
973
|
break;
|
|
@@ -490,46 +976,177 @@ var AcpAgent = class {
|
|
|
490
976
|
this.inflight = true;
|
|
491
977
|
const timeout = setTimeout(() => {
|
|
492
978
|
log(`turn timeout after ${this.config.turnTimeoutMs}ms \u2014 interrupting`);
|
|
979
|
+
this.interruptRequested = true;
|
|
493
980
|
this.api.interrupt(sessionId).catch(() => void 0);
|
|
494
981
|
settle("cancelled");
|
|
495
982
|
}, this.config.turnTimeoutMs);
|
|
983
|
+
let requestPollStop = null;
|
|
984
|
+
if (taskId) {
|
|
985
|
+
requestPollStop = this.startRequestPoll(acpSessionId, taskId, done);
|
|
986
|
+
}
|
|
496
987
|
try {
|
|
497
988
|
await Promise.race([
|
|
498
989
|
bus.waitConnected(),
|
|
499
990
|
new Promise((resolve) => setTimeout(resolve, 1e4))
|
|
500
991
|
]);
|
|
501
|
-
|
|
992
|
+
const turnKey = randomUUID2();
|
|
993
|
+
const messageText = this.config.replyPost && channelId ? `${userText}
|
|
502
994
|
|
|
503
|
-
${BRIDGE_NOTE}
|
|
995
|
+
${BRIDGE_NOTE}` : userText;
|
|
996
|
+
if (systemText) {
|
|
997
|
+
await this.api.sendMessageWithContext(sessionId, messageText, systemText, turnKey);
|
|
998
|
+
} else {
|
|
999
|
+
await this.api.sendMessage(sessionId, messageText, turnKey);
|
|
1000
|
+
}
|
|
1001
|
+
sawTurnActivity = true;
|
|
504
1002
|
const stopReason = await done;
|
|
505
1003
|
if (this.config.replyPost && channelId && replyText.trim()) {
|
|
506
1004
|
await postReply(this.config.buzzBin, channelId, replyText.trim());
|
|
507
|
-
} else if (!channelId && replyText.trim()) {
|
|
1005
|
+
} else if (!channelId && replyText.trim() && this.config.replyPost) {
|
|
508
1006
|
log("no channel UUID found in prompt \u2014 reply not posted to buzz");
|
|
509
1007
|
}
|
|
510
1008
|
return { stopReason };
|
|
511
1009
|
} catch (error) {
|
|
512
1010
|
if (error instanceof ApiError) {
|
|
1011
|
+
if (error.status === 401 || error.status === 403) {
|
|
1012
|
+
throw new RpcError(-32e3, `authentication failed: ${error.message}`);
|
|
1013
|
+
}
|
|
513
1014
|
throw new RpcError(-32603, `agentsky api error (${error.code}): ${error.message}`);
|
|
514
1015
|
}
|
|
515
1016
|
throw error instanceof RpcError ? error : new RpcError(-32603, error instanceof Error ? error.message : String(error));
|
|
516
1017
|
} finally {
|
|
517
1018
|
clearTimeout(timeout);
|
|
518
1019
|
unsubscribe();
|
|
1020
|
+
requestPollStop?.();
|
|
519
1021
|
this.inflight = false;
|
|
520
1022
|
}
|
|
521
1023
|
}
|
|
1024
|
+
// ── Task request forwarding (item 4) ─────────────────────────────────────
|
|
1025
|
+
startRequestPoll(acpSessionId, taskId, turnDone) {
|
|
1026
|
+
let stopped = false;
|
|
1027
|
+
const stop = () => {
|
|
1028
|
+
stopped = true;
|
|
1029
|
+
};
|
|
1030
|
+
void (async () => {
|
|
1031
|
+
const POLL_INTERVAL_MS = 3e3;
|
|
1032
|
+
const handledRequests = /* @__PURE__ */ new Set();
|
|
1033
|
+
while (!stopped) {
|
|
1034
|
+
await Promise.race([
|
|
1035
|
+
new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)),
|
|
1036
|
+
turnDone
|
|
1037
|
+
]);
|
|
1038
|
+
if (stopped) break;
|
|
1039
|
+
let openRequests;
|
|
1040
|
+
try {
|
|
1041
|
+
openRequests = await this.api.listOpenRequests(taskId);
|
|
1042
|
+
} catch {
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
for (const req of openRequests) {
|
|
1046
|
+
if (handledRequests.has(req.id)) continue;
|
|
1047
|
+
handledRequests.add(req.id);
|
|
1048
|
+
void this.handleTaskRequest(acpSessionId, taskId, req);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
})();
|
|
1052
|
+
return stop;
|
|
1053
|
+
}
|
|
1054
|
+
async handleTaskRequest(acpSessionId, taskId, req) {
|
|
1055
|
+
const permissionType = this.mapKindToPermission(req.kind);
|
|
1056
|
+
let granted = false;
|
|
1057
|
+
try {
|
|
1058
|
+
const response = await this.rpc.request(
|
|
1059
|
+
"session/request_permission",
|
|
1060
|
+
{
|
|
1061
|
+
sessionId: acpSessionId ?? "",
|
|
1062
|
+
permission: {
|
|
1063
|
+
type: permissionType,
|
|
1064
|
+
title: req.subject ?? req.kind,
|
|
1065
|
+
description: req.question ?? "",
|
|
1066
|
+
options: req.options,
|
|
1067
|
+
allowFreeText: req.allow_free_text,
|
|
1068
|
+
requestId: req.id
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
this.config.requestTimeoutMs
|
|
1072
|
+
);
|
|
1073
|
+
granted = response?.granted === true;
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
log(
|
|
1076
|
+
`permission request timed out or failed for ${req.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
1077
|
+
);
|
|
1078
|
+
granted = false;
|
|
1079
|
+
}
|
|
1080
|
+
const answer = granted ? { decision: "approved" } : this.d5Fallback(req);
|
|
1081
|
+
try {
|
|
1082
|
+
await this.api.answerRequest(taskId, req.id, answer);
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
log(
|
|
1085
|
+
`failed to answer request ${req.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
mapKindToPermission(kind) {
|
|
1090
|
+
switch (kind) {
|
|
1091
|
+
case "approval":
|
|
1092
|
+
return "execute";
|
|
1093
|
+
case "spend":
|
|
1094
|
+
return "spend";
|
|
1095
|
+
case "connect_app":
|
|
1096
|
+
return "connect";
|
|
1097
|
+
case "connect_account":
|
|
1098
|
+
return "connect";
|
|
1099
|
+
case "question":
|
|
1100
|
+
return "question";
|
|
1101
|
+
default:
|
|
1102
|
+
return kind;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
d5Fallback(req) {
|
|
1106
|
+
switch (req.kind) {
|
|
1107
|
+
case "approval":
|
|
1108
|
+
case "spend":
|
|
1109
|
+
return { decision: "declined", reason: "ACP harness did not grant permission" };
|
|
1110
|
+
case "connect_app":
|
|
1111
|
+
case "connect_account":
|
|
1112
|
+
return {
|
|
1113
|
+
decision: "declined",
|
|
1114
|
+
reason: "ACP harness did not grant permission",
|
|
1115
|
+
link: req.link ?? null
|
|
1116
|
+
};
|
|
1117
|
+
case "question":
|
|
1118
|
+
return {
|
|
1119
|
+
decision: "answered",
|
|
1120
|
+
answer: "No one can answer here; proceed on your best judgement and state what you assumed"
|
|
1121
|
+
};
|
|
1122
|
+
default:
|
|
1123
|
+
return { decision: "declined", reason: "ACP harness did not grant permission" };
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// ── Cancel ────────────────────────────────────────────────────────────────
|
|
522
1127
|
async cancel() {
|
|
523
|
-
if (!this.
|
|
524
|
-
|
|
1128
|
+
if (!this.inflight) return;
|
|
1129
|
+
this.interruptRequested = true;
|
|
1130
|
+
let sessionId = null;
|
|
1131
|
+
if (this.config.sessionMode === "agent" && this.sharedState) {
|
|
1132
|
+
sessionId = this.sharedState.skySessionId;
|
|
1133
|
+
} else {
|
|
1134
|
+
const last = [...this.perAcpSessions.values()].at(-1);
|
|
1135
|
+
if (last) sessionId = last.skySessionId;
|
|
1136
|
+
}
|
|
1137
|
+
if (!sessionId) return;
|
|
1138
|
+
await this.api.interrupt(sessionId).catch((error) => {
|
|
525
1139
|
log(`interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
526
1140
|
});
|
|
1141
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
527
1142
|
}
|
|
528
1143
|
update(sessionId, update) {
|
|
529
1144
|
this.rpc.notify("session/update", { sessionId: sessionId ?? "", update });
|
|
530
1145
|
}
|
|
531
1146
|
shutdown() {
|
|
532
|
-
this.bus
|
|
1147
|
+
for (const state of this.perAcpSessions.values()) state.bus.stop();
|
|
1148
|
+
this.sharedState?.bus.stop();
|
|
1149
|
+
this.bridge.stop();
|
|
533
1150
|
}
|
|
534
1151
|
};
|
|
535
1152
|
|
|
@@ -545,7 +1162,9 @@ async function main() {
|
|
|
545
1162
|
};
|
|
546
1163
|
process.on("SIGINT", shutdown);
|
|
547
1164
|
process.on("SIGTERM", shutdown);
|
|
548
|
-
log(
|
|
1165
|
+
log(
|
|
1166
|
+
`agentsky-acp ${VERSION} \u2014 agent ${config.agent} via ${config.apiBase} (session=${config.sessionMode}, context-block=${config.contextBlock})`
|
|
1167
|
+
);
|
|
549
1168
|
await rpc.run();
|
|
550
1169
|
shutdown();
|
|
551
1170
|
}
|
package/package.json
CHANGED