letagents 0.12.11 → 0.12.13
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/dist/mcp/git-remote.js +7 -7
- package/dist/mcp/local-state/agent-sessions.js +78 -5
- package/dist/mcp/local-state/local-chat.js +189 -48
- package/dist/mcp/local-state/storage.js +16 -9
- package/dist/mcp/server/daemon-tool-executor.js +92 -0
- package/dist/mcp/server/register-tools.js +25 -12
- package/dist/mcp/server/runtime/agent-sessions.js +58 -9
- package/dist/mcp/server/runtime/api.js +40 -4
- package/dist/mcp/server/runtime/daemon-tool-context.js +11 -0
- package/dist/mcp/server/runtime/execution-profile.js +19 -0
- package/dist/mcp/server/runtime/identity/directory.js +2 -2
- package/dist/mcp/server/runtime/messages.js +55 -0
- package/dist/mcp/server/runtime/presence.js +4 -2
- package/dist/mcp/server/runtime/room-api.js +24 -7
- package/dist/mcp/server/runtime/room-state.js +59 -3
- package/dist/mcp/server/runtime/rooms.js +67 -32
- package/dist/mcp/server/runtime/supervised-room-authority.js +8 -0
- package/dist/mcp/server/runtime/supervisor-bridge.js +702 -24
- package/dist/mcp/server/runtime/tool-surface-policy.js +26 -0
- package/dist/mcp/server/runtime/worker-bearer.js +44 -6
- package/dist/mcp/server/runtime-contract.js +27 -0
- package/dist/mcp/server/runtime.js +15 -4
- package/dist/mcp/server/supervised-tool-facade.js +134 -0
- package/dist/mcp/server/tools/agent-sessions.js +74 -7
- package/dist/mcp/server/tools/messages/index.js +3 -2
- package/dist/mcp/server/tools/messages/read-tool.js +54 -97
- package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -0
- package/dist/mcp/server/tools/messages/send-tool.js +5 -0
- package/dist/mcp/server/tools/messages/status-tool.js +2 -0
- package/dist/mcp/server/tools/messages/wait-tool.js +344 -71
- package/dist/mcp/server/tools/onboarding/status-tool.js +9 -8
- package/dist/mcp/server/tools/rooms/inspection-tools.js +40 -22
- package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +2 -1
- package/dist/mcp/server/tools/supervised-room-turn.js +42 -0
- package/dist/mcp/server/tools/tasks/board-tools.js +34 -2
- package/dist/mcp/server.js +14 -7
- package/dist/mcp/sse-client.js +163 -20
- package/dist/shared/activation-routing.js +187 -20
- package/dist/shared/agent-presence.js +6 -0
- package/dist/shared/desktop-release-manifest.js +63 -0
- package/dist/shared/desktop-release.js +60 -0
- package/dist/shared/scoped-ids.js +6 -0
- package/package.json +11 -3
- package/shared/message-contracts.d.mts +32 -0
- package/shared/message-contracts.mjs +109 -0
- package/shared/routing-aliases.d.mts +18 -0
- package/shared/routing-aliases.mjs +66 -0
- package/shared/sqlite-thread-routing.d.mts +72 -0
- package/shared/sqlite-thread-routing.mjs +1038 -0
|
@@ -3,8 +3,10 @@ import { join } from "path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { getRoomFromConfig } from "../../../config-reader.js";
|
|
5
5
|
import { buildActiveGitRoomContext, getGitCurrentBranch, getGitDefaultBranch, getGitRoomContext, } from "../../../git-remote.js";
|
|
6
|
-
import { currentAgentIdentity, currentAgentIdentityKey, currentRoom, getConversationIdentity, getCurrentLiveSessionPayload, getStoredAgentIdentity, listStoredCodexLiveSessions, toPublicAgentIdentity,
|
|
6
|
+
import { currentAgentIdentity, currentAgentIdentityKey, currentRoomMatchesLocator, currentRoom, getConversationIdentity, getCurrentLiveSessionPayload, getStoredAgentIdentity, listStoredCodexLiveSessions, toPublicAgentIdentity, toPublicCurrentRoomState, withJoinRoomAgentPrompt, } from "../../runtime.js";
|
|
7
7
|
import { requireValidWorkerBearerRuntime } from "../../runtime/worker-bearer.js";
|
|
8
|
+
import { getDaemonToolExecutionContext, getRuntimeWorkingDirectory } from "../../runtime/daemon-tool-context.js";
|
|
9
|
+
import { identityFromAgentSession } from "../../runtime/agent-sessions.js";
|
|
8
10
|
import { findExistingConfig, resolveGitRoot, } from "../../repo-context.js";
|
|
9
11
|
import { jsonToolResponse } from "./response.js";
|
|
10
12
|
let ownerAuthStoreLoader = () => import("../../../local-state.js");
|
|
@@ -29,16 +31,18 @@ export function registerRoomInspectionTools(server) {
|
|
|
29
31
|
}
|
|
30
32
|
export async function getCurrentRoomPayload(conversationId) {
|
|
31
33
|
const runtime = requireValidWorkerBearerRuntime();
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
+
const daemonContext = getDaemonToolExecutionContext();
|
|
35
|
+
const publicCurrentRoom = toPublicCurrentRoomState();
|
|
36
|
+
const workerAuth = runtime.mode !== "owner"
|
|
37
|
+
? { source: runtime.mode === "worker" ? "worker_bearer" : "daemon_supervised", expires_at: null, account: null }
|
|
34
38
|
: null;
|
|
35
|
-
const localCodexDetails = runtime.mode
|
|
39
|
+
const localCodexDetails = runtime.mode !== "owner"
|
|
36
40
|
? { current_local_codex_session: null, local_codex_session_count: 0 }
|
|
37
41
|
: {
|
|
38
42
|
current_local_codex_session: getCurrentLiveSessionPayload(currentRoom?.room_id),
|
|
39
43
|
local_codex_session_count: listStoredCodexLiveSessions().length,
|
|
40
44
|
};
|
|
41
|
-
if (!
|
|
45
|
+
if (!publicCurrentRoom) {
|
|
42
46
|
return {
|
|
43
47
|
connected: false,
|
|
44
48
|
message: "Not currently in any room",
|
|
@@ -46,16 +50,19 @@ export async function getCurrentRoomPayload(conversationId) {
|
|
|
46
50
|
...(workerAuth ? { auth: workerAuth } : {}),
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
|
-
const auth = runtime.mode
|
|
53
|
+
const auth = runtime.mode !== "owner"
|
|
50
54
|
? null
|
|
51
55
|
: (await ownerAuthStoreLoader()).getStoredAuth();
|
|
52
|
-
|
|
56
|
+
const payload = {
|
|
53
57
|
connected: true,
|
|
54
|
-
...
|
|
58
|
+
...publicCurrentRoom,
|
|
59
|
+
...(runtime.mode === "supervised" ? { room_binding: "daemon_supervised" } : {}),
|
|
55
60
|
...localCodexDetails,
|
|
56
|
-
agent_identity: toPublicAgentIdentity(
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
agent_identity: toPublicAgentIdentity(daemonContext
|
|
62
|
+
? identityFromAgentSession(daemonContext.agentSession)
|
|
63
|
+
: getConversationIdentity(conversationId)
|
|
64
|
+
?? currentAgentIdentity
|
|
65
|
+
?? getStoredAgentIdentity(currentAgentIdentityKey)),
|
|
59
66
|
auth: workerAuth ?? (auth
|
|
60
67
|
? {
|
|
61
68
|
source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
|
|
@@ -63,10 +70,15 @@ export async function getCurrentRoomPayload(conversationId) {
|
|
|
63
70
|
account: auth.account ?? null,
|
|
64
71
|
}
|
|
65
72
|
: null),
|
|
66
|
-
}
|
|
73
|
+
};
|
|
74
|
+
// A daemon-supervised worker is already bound to its exact authorized room.
|
|
75
|
+
// A normal join prompt is both false and dangerous here because it can lead
|
|
76
|
+
// the provider to request an unrelated room move.
|
|
77
|
+
return runtime.mode === "supervised" ? payload : withJoinRoomAgentPrompt(payload);
|
|
67
78
|
}
|
|
68
79
|
function getRepoInspectionPayload(targetDir) {
|
|
69
|
-
const
|
|
80
|
+
const runtime = requireValidWorkerBearerRuntime();
|
|
81
|
+
const startDir = targetDir || getRuntimeWorkingDirectory();
|
|
70
82
|
const repoRoot = resolveGitRoot(startDir);
|
|
71
83
|
const configDir = repoRoot ? findExistingConfig(startDir) : null;
|
|
72
84
|
const configPath = configDir ? join(configDir, ".letagents.json") : null;
|
|
@@ -79,8 +91,8 @@ function getRepoInspectionPayload(targetDir) {
|
|
|
79
91
|
defaultBranch: repoRoot ? getGitDefaultBranch(repoRoot) : null,
|
|
80
92
|
})
|
|
81
93
|
: null;
|
|
82
|
-
const detectedRoom = configGitContext?.
|
|
83
|
-
const currentRoomMatchesContext =
|
|
94
|
+
const detectedRoom = configGitContext?.activeRoomLocator ?? gitContext?.activeRoomLocator ?? null;
|
|
95
|
+
const currentRoomMatchesContext = currentRoomMatchesLocator(detectedRoom);
|
|
84
96
|
return {
|
|
85
97
|
cwd: startDir,
|
|
86
98
|
repo_context_status: repoRoot ? "git_repo_detected" : "not_inside_git_repo",
|
|
@@ -88,17 +100,23 @@ function getRepoInspectionPayload(targetDir) {
|
|
|
88
100
|
config_file: configPath ?? null,
|
|
89
101
|
config_contents: readConfigContents(configPath),
|
|
90
102
|
configured_room_from_file: configuredRoom ?? null,
|
|
91
|
-
configured_active_room_from_context: configGitContext?.
|
|
92
|
-
derived_room_from_git: gitContext?.
|
|
103
|
+
configured_active_room_from_context: configGitContext?.activeRoomLocator ?? null,
|
|
104
|
+
derived_room_from_git: gitContext?.activeRoomLocator ?? null,
|
|
93
105
|
derived_repo_room_from_git: gitContext?.repoRoom ?? null,
|
|
94
|
-
derived_branch_room_from_git: gitContext?.
|
|
106
|
+
derived_branch_room_from_git: gitContext?.activeRefRoomLocator ?? null,
|
|
95
107
|
git_current_branch: gitContext?.currentBranch ?? configGitContext?.currentBranch ?? null,
|
|
96
108
|
git_default_branch: gitContext?.defaultBranch ?? configGitContext?.defaultBranch ?? null,
|
|
97
109
|
detected_room_from_context: detectedRoom ?? null,
|
|
98
|
-
current_room:
|
|
99
|
-
current_room_scope:
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
current_room: toPublicCurrentRoomState(),
|
|
111
|
+
current_room_scope: runtime.mode === "supervised"
|
|
112
|
+
? "daemon_supervised_exact_room"
|
|
113
|
+
: currentRoomScope(currentRoomMatchesContext, detectedRoom),
|
|
114
|
+
warning: runtime.mode === "supervised"
|
|
115
|
+
? null
|
|
116
|
+
: repoWarning({ repoRoot, detectedRoom, currentRoomMatchesContext }),
|
|
117
|
+
join_hint: runtime.mode === "supervised"
|
|
118
|
+
? null
|
|
119
|
+
: joinHint({ repoRoot, detectedRoom, currentRoomMatchesContext }),
|
|
102
120
|
};
|
|
103
121
|
}
|
|
104
122
|
function readConfigContents(configPath) {
|
|
@@ -5,6 +5,7 @@ import { getGitRemoteIdentity } from "../../../git-remote.js";
|
|
|
5
5
|
import { joinRoomIdentifier, } from "../../runtime.js";
|
|
6
6
|
import { findExistingConfig, resolveGitRoot, } from "../../repo-context.js";
|
|
7
7
|
import { jsonToolResponse } from "./response.js";
|
|
8
|
+
import { getRuntimeWorkingDirectory } from "../../runtime/daemon-tool-context.js";
|
|
8
9
|
export function registerRepoInitializationTool(server) {
|
|
9
10
|
server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat by creating a .letagents.json config file. " +
|
|
10
11
|
"This explicitly sets up repo-based room auto-join. Reads git remote to derive the room name, " +
|
|
@@ -19,7 +20,7 @@ export function registerRepoInitializationTool(server) {
|
|
|
19
20
|
.optional()
|
|
20
21
|
.describe("Working directory hint for repo detection. Defaults to the current process directory."),
|
|
21
22
|
}, async ({ room, cwd: targetDir }) => {
|
|
22
|
-
const startDir = targetDir ||
|
|
23
|
+
const startDir = targetDir || getRuntimeWorkingDirectory();
|
|
23
24
|
const repoRoot = resolveGitRoot(startDir);
|
|
24
25
|
if (!repoRoot) {
|
|
25
26
|
return jsonToolResponse({
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const MAX_ROOM_REPLY_BYTES = 32 * 1024;
|
|
3
|
+
function completionResponse(outcome) {
|
|
4
|
+
const payload = {
|
|
5
|
+
accepted: true,
|
|
6
|
+
outcome,
|
|
7
|
+
instruction: "The daemon recorded this exact turn completion. End the provider turn without sending the activating reply through another tool.",
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
11
|
+
structuredContent: payload,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Cursor's documented terminal `result` is the concatenation of every
|
|
16
|
+
* assistant delta, including progress narration. A supervised provider must
|
|
17
|
+
* therefore propose its public reply over a distinct, turn-scoped channel.
|
|
18
|
+
*
|
|
19
|
+
* This tool is registered only for `supervised_room_turn`. The normal
|
|
20
|
+
* supervised tool facade binds it to the exact provider turn and commits its
|
|
21
|
+
* request/result through the durable bounded-effect journal before the daemon
|
|
22
|
+
* will consider it publishable.
|
|
23
|
+
*/
|
|
24
|
+
export function registerSupervisedRoomTurnTools(server) {
|
|
25
|
+
server.tool("complete_room_turn", "Record the single public completion for this supervised room turn. Call exactly once after all work is finished. Progress text outside this tool is live activity only and is never published as the room reply.", {
|
|
26
|
+
outcome: z.enum(["reply", "no_reply"]).describe("Use reply to publish one answer, or no_reply when the activating message needs no response."),
|
|
27
|
+
text: z.string().optional().describe("The concise public room reply. Required only when outcome is reply."),
|
|
28
|
+
}, async ({ outcome, text }) => {
|
|
29
|
+
const normalized = text?.trim() ?? "";
|
|
30
|
+
if (outcome === "reply") {
|
|
31
|
+
if (!normalized)
|
|
32
|
+
throw new Error("complete_room_turn requires non-empty text when outcome is reply.");
|
|
33
|
+
if (Buffer.byteLength(normalized, "utf8") > MAX_ROOM_REPLY_BYTES) {
|
|
34
|
+
throw new Error("complete_room_turn reply exceeds the 32 KiB public-message limit.");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (normalized) {
|
|
38
|
+
throw new Error("complete_room_turn must omit text when outcome is no_reply.");
|
|
39
|
+
}
|
|
40
|
+
return completionResponse(outcome);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -4,6 +4,34 @@ import { createTask, listTasks } from "./api.js";
|
|
|
4
4
|
import { resolveTaskToolIdentity, resolveTaskToolTarget, taskActorPayload, } from "./context.js";
|
|
5
5
|
import { jsonToolResponse, taskToolError } from "./response.js";
|
|
6
6
|
import { boardIntentApprovalSchema, TASK_STATUSES, workerTaskIdentitySchema } from "./schemas.js";
|
|
7
|
+
export const MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK = 4;
|
|
8
|
+
export const MAX_BOARD_WORKFLOW_REFS_PER_TASK = 4;
|
|
9
|
+
function compactBoardWorkflowArtifact(value) {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
11
|
+
return value;
|
|
12
|
+
const artifact = value;
|
|
13
|
+
// A change-summary detail may itself contain hundreds of files. The board is
|
|
14
|
+
// an index; callers that need complete artifact detail use get_room_artifacts.
|
|
15
|
+
return Object.fromEntries(Object.entries(artifact).filter(([key]) => key !== "detail"));
|
|
16
|
+
}
|
|
17
|
+
export function compactTaskForBoard(value) {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
return value;
|
|
20
|
+
const task = value;
|
|
21
|
+
const artifacts = Array.isArray(task.workflow_artifacts) ? task.workflow_artifacts : [];
|
|
22
|
+
const refs = Array.isArray(task.workflow_refs) ? task.workflow_refs : [];
|
|
23
|
+
return {
|
|
24
|
+
...task,
|
|
25
|
+
workflow_artifacts: artifacts
|
|
26
|
+
.slice(-MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK)
|
|
27
|
+
.map(compactBoardWorkflowArtifact),
|
|
28
|
+
workflow_refs: refs.slice(-MAX_BOARD_WORKFLOW_REFS_PER_TASK),
|
|
29
|
+
workflow_artifact_count: artifacts.length,
|
|
30
|
+
workflow_ref_count: refs.length,
|
|
31
|
+
workflow_artifacts_truncated: artifacts.length > MAX_BOARD_WORKFLOW_ARTIFACTS_PER_TASK,
|
|
32
|
+
workflow_refs_truncated: refs.length > MAX_BOARD_WORKFLOW_REFS_PER_TASK,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
7
35
|
export function registerTaskBoardTools(server) {
|
|
8
36
|
server.tool("add_task", "Add a new task to the room board. Tasks normally start as 'proposed' and must be " +
|
|
9
37
|
"accepted before an agent can claim them. Agent-created tasks require coordinator " +
|
|
@@ -59,7 +87,7 @@ export function registerTaskBoardTools(server) {
|
|
|
59
87
|
const qs = pageParams.toString();
|
|
60
88
|
const result = await listTasks(target, qs);
|
|
61
89
|
const tasks = result.tasks ?? [];
|
|
62
|
-
allTasks.push(...tasks);
|
|
90
|
+
allTasks.push(...tasks.map(compactTaskForBoard));
|
|
63
91
|
if (!result.has_more || tasks.length === 0)
|
|
64
92
|
break;
|
|
65
93
|
const lastTask = tasks[tasks.length - 1];
|
|
@@ -68,6 +96,10 @@ export function registerTaskBoardTools(server) {
|
|
|
68
96
|
afterCursor = lastTask.id;
|
|
69
97
|
}
|
|
70
98
|
await heartbeatRoomPresence(target.effectiveRoomId, await ensureAgentIdentity());
|
|
71
|
-
return jsonToolResponse({
|
|
99
|
+
return jsonToolResponse({
|
|
100
|
+
success: true,
|
|
101
|
+
tasks: allTasks,
|
|
102
|
+
artifact_detail_instruction: "Board tasks contain bounded artifact summaries. Use get_room_artifacts for complete workflow artifact detail.",
|
|
103
|
+
}, 2);
|
|
72
104
|
});
|
|
73
105
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -5,14 +5,21 @@ import { registerRoomResources } from "./server/resources.js";
|
|
|
5
5
|
import { registerTools } from "./server/register-tools.js";
|
|
6
6
|
import { attachMcpServer, autoJoinFromContext, shutdownRuntime } from "./server/runtime.js";
|
|
7
7
|
import { requireValidWorkerBearerRuntime } from "./server/runtime/worker-bearer.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
version: "0.2.0",
|
|
11
|
-
});
|
|
12
|
-
attachMcpServer(server);
|
|
13
|
-
registerRoomResources(server);
|
|
14
|
-
registerTools(server);
|
|
8
|
+
import { executionProfile } from "./server/runtime/execution-profile.js";
|
|
9
|
+
import { LETAGENTS_RUNTIME_CONTRACT_ARG, letAgentsRuntimeContract, } from "./server/runtime-contract.js";
|
|
15
10
|
async function main() {
|
|
11
|
+
if (process.argv.slice(2).includes(LETAGENTS_RUNTIME_CONTRACT_ARG)) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(letAgentsRuntimeContract())}\n`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const server = new McpServer({
|
|
16
|
+
name: "letagents",
|
|
17
|
+
version: "0.2.0",
|
|
18
|
+
});
|
|
19
|
+
attachMcpServer(server);
|
|
20
|
+
registerRoomResources(server);
|
|
21
|
+
const profile = executionProfile();
|
|
22
|
+
registerTools(server, profile);
|
|
16
23
|
requireValidWorkerBearerRuntime();
|
|
17
24
|
const transport = new StdioServerTransport();
|
|
18
25
|
await server.connect(transport);
|
package/dist/mcp/sse-client.js
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { encodeRoomIdPath } from "./room-id.js";
|
|
2
|
+
const MAX_SSE_FRAME_BYTES = 1024 * 1024;
|
|
3
|
+
class SseFrameOverflowError extends Error {
|
|
4
|
+
constructor() {
|
|
5
|
+
super("SSE frame exceeded bounded size");
|
|
6
|
+
this.name = "SseFrameOverflowError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
class SseFrameMalformedError extends Error {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
super("SSE frame was malformed", options);
|
|
12
|
+
this.name = "SseFrameMalformedError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
2
15
|
export class SseClient {
|
|
3
16
|
apiUrl;
|
|
4
17
|
getAccessToken;
|
|
5
18
|
subscriptions = new Map();
|
|
19
|
+
eventCursors = new Map();
|
|
6
20
|
constructor(apiUrl, getAccessToken) {
|
|
7
21
|
this.apiUrl = apiUrl.replace(/\/$/, "");
|
|
8
22
|
this.getAccessToken = getAccessToken;
|
|
9
23
|
}
|
|
10
|
-
subscribe(target, onMessage) {
|
|
24
|
+
subscribe(target, onMessage, onGap) {
|
|
11
25
|
const subscriptionKey = target.roomId;
|
|
12
26
|
if (this.subscriptions.has(subscriptionKey)) {
|
|
13
27
|
return;
|
|
14
28
|
}
|
|
15
29
|
const controller = new AbortController();
|
|
16
|
-
const promise = this.consumeStream(target, controller.signal, onMessage)
|
|
30
|
+
const promise = this.consumeStream(target, controller.signal, onMessage, onGap)
|
|
17
31
|
.catch((error) => {
|
|
18
32
|
if (controller.signal.aborted) {
|
|
19
33
|
return;
|
|
@@ -51,17 +65,41 @@ export class SseClient {
|
|
|
51
65
|
Authorization: `Bearer ${token}`,
|
|
52
66
|
};
|
|
53
67
|
}
|
|
54
|
-
async consumeStream(target, signal, onMessage) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
async consumeStream(target, signal, onMessage, onGap) {
|
|
69
|
+
let lastEventId = this.eventCursors.get(target.roomId) ?? null;
|
|
70
|
+
let useLegacyRoute = false;
|
|
71
|
+
let retryMs = 1_000;
|
|
72
|
+
while (!signal.aborted) {
|
|
73
|
+
const baseUrl = useLegacyRoute && target.projectId
|
|
74
|
+
? `${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`
|
|
75
|
+
: `${this.apiUrl}/rooms/${encodeRoomIdPath(target.roomId)}/messages/stream`;
|
|
76
|
+
const url = this.withIncludePromptOnly(this.withAgentIdentityQuery(baseUrl, target));
|
|
77
|
+
try {
|
|
78
|
+
lastEventId = await this.openStream(url, signal, target.roomId, lastEventId, onMessage, onGap);
|
|
79
|
+
retryMs = 1_000;
|
|
62
80
|
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (signal.aborted)
|
|
83
|
+
return;
|
|
84
|
+
if (error instanceof SseFrameOverflowError || error instanceof SseFrameMalformedError) {
|
|
85
|
+
lastEventId = null;
|
|
86
|
+
}
|
|
87
|
+
if (this.isMissingRouteError(error)) {
|
|
88
|
+
if (!useLegacyRoute && target.projectId) {
|
|
89
|
+
useLegacyRoute = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// A missing room/route is terminal. Retrying it forever creates
|
|
93
|
+
// background traffic and can never heal without a new room join.
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
console.error(`SSE stream disconnected for room ${target.roomId}:`, error);
|
|
97
|
+
}
|
|
98
|
+
if (signal.aborted)
|
|
99
|
+
return;
|
|
100
|
+
await waitForRetry(signal, retryMs);
|
|
101
|
+
retryMs = Math.min(Math.ceil(retryMs * 1.5), 30_000);
|
|
63
102
|
}
|
|
64
|
-
await this.openStream(this.withIncludePromptOnly(this.withAgentIdentityQuery(`${this.apiUrl}/projects/${encodeURIComponent(target.projectId)}/messages/stream`, target)), signal, onMessage);
|
|
65
103
|
}
|
|
66
104
|
withIncludePromptOnly(url) {
|
|
67
105
|
return `${url}${url.includes("?") ? "&" : "?"}include_prompt_only=1`;
|
|
@@ -80,9 +118,14 @@ export class SseClient {
|
|
|
80
118
|
}
|
|
81
119
|
return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
82
120
|
}
|
|
83
|
-
async openStream(url, signal, onMessage) {
|
|
121
|
+
async openStream(url, signal, roomId, lastEventId, onMessage, onGap) {
|
|
122
|
+
const headers = await this.getHeaders();
|
|
123
|
+
if (signal.aborted)
|
|
124
|
+
return lastEventId;
|
|
125
|
+
if (lastEventId)
|
|
126
|
+
headers["Last-Event-ID"] = lastEventId;
|
|
84
127
|
const response = await fetch(url, {
|
|
85
|
-
headers
|
|
128
|
+
headers,
|
|
86
129
|
signal,
|
|
87
130
|
});
|
|
88
131
|
if (!response.ok) {
|
|
@@ -94,43 +137,143 @@ export class SseClient {
|
|
|
94
137
|
const reader = response.body.getReader();
|
|
95
138
|
const decoder = new TextDecoder();
|
|
96
139
|
let buffer = "";
|
|
140
|
+
let bufferBytes = 0;
|
|
97
141
|
while (!signal.aborted) {
|
|
98
142
|
const { value, done } = await reader.read();
|
|
99
143
|
if (done) {
|
|
100
144
|
break;
|
|
101
145
|
}
|
|
102
146
|
buffer += decoder.decode(value, { stream: true });
|
|
147
|
+
bufferBytes += value.byteLength;
|
|
148
|
+
if (bufferBytes > MAX_SSE_FRAME_BYTES) {
|
|
149
|
+
// The cursor is no longer safe: an unterminated/oversize frame may
|
|
150
|
+
// contain an event we could not parse. Force pull-state repair before
|
|
151
|
+
// reconnecting and never retain attacker-controlled remainder bytes.
|
|
152
|
+
this.eventCursors.delete(roomId);
|
|
153
|
+
try {
|
|
154
|
+
onGap?.({ room_id: roomId, event_cursor: null, gap: true });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
console.error("SSE gap callback failed:", error);
|
|
158
|
+
}
|
|
159
|
+
await reader.cancel("SSE frame exceeded bounded size").catch(() => undefined);
|
|
160
|
+
throw new SseFrameOverflowError();
|
|
161
|
+
}
|
|
103
162
|
let boundaryIndex = buffer.indexOf("\n\n");
|
|
163
|
+
let consumedFrame = false;
|
|
104
164
|
while (boundaryIndex !== -1) {
|
|
105
165
|
const rawEvent = buffer.slice(0, boundaryIndex);
|
|
106
166
|
buffer = buffer.slice(boundaryIndex + 2);
|
|
107
|
-
|
|
167
|
+
try {
|
|
168
|
+
lastEventId = this.handleEvent(rawEvent, lastEventId, onMessage, onGap);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
// A complete but malformed typed frame is the same lost-boundary
|
|
172
|
+
// condition as an oversized frame. Never reconnect from its cursor
|
|
173
|
+
// and replay it forever; force the authorized pull-state repair.
|
|
174
|
+
this.eventCursors.delete(roomId);
|
|
175
|
+
try {
|
|
176
|
+
onGap?.({ room_id: roomId, event_cursor: null, gap: true });
|
|
177
|
+
}
|
|
178
|
+
catch (gapError) {
|
|
179
|
+
console.error("SSE gap callback failed:", gapError);
|
|
180
|
+
}
|
|
181
|
+
await reader.cancel("SSE frame was malformed").catch(() => undefined);
|
|
182
|
+
throw new SseFrameMalformedError({ cause: error });
|
|
183
|
+
}
|
|
184
|
+
this.rememberEventCursor(roomId, lastEventId);
|
|
185
|
+
consumedFrame = true;
|
|
108
186
|
boundaryIndex = buffer.indexOf("\n\n");
|
|
109
187
|
}
|
|
188
|
+
if (consumedFrame)
|
|
189
|
+
bufferBytes = new TextEncoder().encode(buffer).byteLength;
|
|
110
190
|
}
|
|
111
191
|
const trailing = buffer + decoder.decode();
|
|
112
192
|
if (trailing.trim()) {
|
|
113
|
-
|
|
193
|
+
try {
|
|
194
|
+
lastEventId = this.handleEvent(trailing, lastEventId, onMessage, onGap);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.eventCursors.delete(roomId);
|
|
198
|
+
try {
|
|
199
|
+
onGap?.({ room_id: roomId, event_cursor: null, gap: true });
|
|
200
|
+
}
|
|
201
|
+
catch (gapError) {
|
|
202
|
+
console.error("SSE gap callback failed:", gapError);
|
|
203
|
+
}
|
|
204
|
+
throw new SseFrameMalformedError({ cause: error });
|
|
205
|
+
}
|
|
206
|
+
this.rememberEventCursor(roomId, lastEventId);
|
|
114
207
|
}
|
|
208
|
+
return lastEventId;
|
|
115
209
|
}
|
|
116
|
-
handleEvent(rawEvent, onMessage) {
|
|
210
|
+
handleEvent(rawEvent, lastEventId, onMessage, onGap) {
|
|
117
211
|
const normalizedEvent = rawEvent.replace(/\r/g, "");
|
|
118
|
-
const
|
|
119
|
-
|
|
212
|
+
const lines = normalizedEvent.split("\n");
|
|
213
|
+
const eventName = lines.find((line) => line.startsWith("event:"))?.slice(6).trim() || "message";
|
|
214
|
+
const frameId = lines.find((line) => line.startsWith("id:"))?.slice(3).trim() || null;
|
|
215
|
+
const dataLines = lines
|
|
120
216
|
.filter((line) => line.startsWith("data:"))
|
|
121
217
|
.map((line) => line.slice(5).trimStart());
|
|
122
218
|
if (dataLines.length === 0) {
|
|
123
|
-
return;
|
|
219
|
+
return frameId ?? lastEventId;
|
|
124
220
|
}
|
|
125
221
|
// No prompt enrichment here: the only SSE subscriber (room-state.ts) discards
|
|
126
222
|
// the message body, and expanding the room-agent prompt on this background
|
|
127
223
|
// path would consume the once-per-session full-prompt delivery before the
|
|
128
224
|
// agent ever sees it. Prompt expansion happens only on visible tool
|
|
129
225
|
// responses (runtime/messages.ts).
|
|
130
|
-
|
|
226
|
+
const payload = JSON.parse(dataLines.join("\n"));
|
|
227
|
+
if (eventName === "room_sync") {
|
|
228
|
+
if (payload.gap === true) {
|
|
229
|
+
try {
|
|
230
|
+
onGap?.(payload);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
console.error("SSE gap callback failed:", error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (Object.prototype.hasOwnProperty.call(payload, "event_cursor")) {
|
|
237
|
+
return typeof payload.event_cursor === "string" ? payload.event_cursor : null;
|
|
238
|
+
}
|
|
239
|
+
return frameId ?? lastEventId;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
onMessage(payload);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
console.error("SSE message callback failed:", error);
|
|
246
|
+
}
|
|
247
|
+
return frameId ?? lastEventId;
|
|
131
248
|
}
|
|
132
249
|
isMissingRouteError(error) {
|
|
133
250
|
return (error instanceof Error &&
|
|
134
251
|
/status 404|status 405|Cannot (GET|POST|PATCH)/.test(error.message));
|
|
135
252
|
}
|
|
253
|
+
rememberEventCursor(roomId, cursor) {
|
|
254
|
+
this.eventCursors.delete(roomId);
|
|
255
|
+
if (!cursor)
|
|
256
|
+
return;
|
|
257
|
+
this.eventCursors.set(roomId, cursor);
|
|
258
|
+
while (this.eventCursors.size > 32) {
|
|
259
|
+
const oldestRoomId = this.eventCursors.keys().next().value;
|
|
260
|
+
if (!oldestRoomId)
|
|
261
|
+
break;
|
|
262
|
+
this.eventCursors.delete(oldestRoomId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function waitForRetry(signal, delayMs) {
|
|
267
|
+
if (signal.aborted)
|
|
268
|
+
return Promise.resolve();
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
const timer = setTimeout(finish, delayMs);
|
|
271
|
+
timer.unref?.();
|
|
272
|
+
function finish() {
|
|
273
|
+
signal.removeEventListener("abort", finish);
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
resolve();
|
|
276
|
+
}
|
|
277
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
278
|
+
});
|
|
136
279
|
}
|