@cueloop/daemon 0.1.0-alpha.96 → 0.1.0-alpha.98
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/package.json +3 -3
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/capabilities.ts +6 -0
- package/src/client.test.ts +13 -0
- package/src/client.ts +50 -3
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/server.ts +151 -2
- package/src/thread-agent-validation.ts +108 -0
- package/src/thread-agent.test.ts +307 -0
- package/src/thread-agent.ts +666 -0
- package/src/validate.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.98",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
43
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
42
|
+
"@cueloop/extension-api": "0.1.0-alpha.98",
|
|
43
|
+
"@cueloop/schema": "0.1.0-alpha.98",
|
|
44
44
|
"@pierre/diffs": "^1.4.1",
|
|
45
45
|
"effect": "^4.0.0-rc.117",
|
|
46
46
|
"smol-toml": "^1.8.0",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { MESSAGE_OUTCOMES, type Thread } from "@cueloop/schema";
|
|
4
|
+
import type { MethodName } from "./validate";
|
|
5
|
+
|
|
6
|
+
/** Tool arguments are validated before entering the owner's daemon API. */
|
|
7
|
+
export function parseAgentToolInput(name: string, serialized: string) {
|
|
8
|
+
const input: unknown = JSON.parse(serialized);
|
|
9
|
+
|
|
10
|
+
if (name === "reply_to_comment") {
|
|
11
|
+
const args = v.parse(
|
|
12
|
+
v.object({
|
|
13
|
+
id: v.string(),
|
|
14
|
+
commentId: v.string(),
|
|
15
|
+
body: v.pipe(v.string(), v.minLength(1), v.maxLength(32768)),
|
|
16
|
+
}),
|
|
17
|
+
input,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return { kind: "reply" as const, ...args };
|
|
21
|
+
}
|
|
22
|
+
if (name === "send_message") {
|
|
23
|
+
const args = v.parse(
|
|
24
|
+
v.object({
|
|
25
|
+
id: v.string(),
|
|
26
|
+
outcome: v.picklist(MESSAGE_OUTCOMES),
|
|
27
|
+
summary: v.optional(v.string(), ""),
|
|
28
|
+
}),
|
|
29
|
+
input,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return { kind: "api" as const, method: "session.sendMessage", params: args };
|
|
33
|
+
}
|
|
34
|
+
if (name !== "cueloop_api") throw new Error("Thread agent tool name is unavailable");
|
|
35
|
+
const args = v.parse(v.object({ method: v.string(), params: v.unknown() }), input);
|
|
36
|
+
|
|
37
|
+
return { kind: "api" as const, ...args };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const THREAD_AGENT_SESSION_METHODS = new Set<string>([
|
|
41
|
+
"session.get",
|
|
42
|
+
"session.wait",
|
|
43
|
+
"session.annotate",
|
|
44
|
+
"session.comment",
|
|
45
|
+
"session.removeAnnotation",
|
|
46
|
+
"session.setParticipantName",
|
|
47
|
+
"session.setWorkingCopy",
|
|
48
|
+
"session.cutBlock",
|
|
49
|
+
"session.navigate",
|
|
50
|
+
"session.branch",
|
|
51
|
+
"session.switch",
|
|
52
|
+
"session.label",
|
|
53
|
+
"session.restoreBlock",
|
|
54
|
+
"session.curate",
|
|
55
|
+
"session.setViewed",
|
|
56
|
+
"session.setTitle",
|
|
57
|
+
"session.projectFiles",
|
|
58
|
+
"session.fileContents",
|
|
59
|
+
"session.refreshDiff",
|
|
60
|
+
"session.sendMessage",
|
|
61
|
+
"session.submitRevision",
|
|
62
|
+
] satisfies MethodName[]);
|
|
63
|
+
const THREAD_AGENT_REPO_METHODS = new Set<string>([
|
|
64
|
+
"repo.files",
|
|
65
|
+
"repo.fileContents",
|
|
66
|
+
"repo.changes",
|
|
67
|
+
"repo.diff",
|
|
68
|
+
] satisfies MethodName[]);
|
|
69
|
+
|
|
70
|
+
/** Harness tools are bound to their originating Thread; model arguments cannot widen that scope. */
|
|
71
|
+
export function assertAgentToolScope(
|
|
72
|
+
thread: Thread,
|
|
73
|
+
input: ReturnType<typeof parseAgentToolInput>,
|
|
74
|
+
): void {
|
|
75
|
+
if (input.kind === "reply") {
|
|
76
|
+
if (input.id !== thread.id) throw new Error("Thread agent tool cannot access another Thread");
|
|
77
|
+
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (input.method === "session.list") return;
|
|
81
|
+
const params = v.parse(v.record(v.string(), v.unknown()), input.params);
|
|
82
|
+
|
|
83
|
+
if (THREAD_AGENT_SESSION_METHODS.has(input.method)) {
|
|
84
|
+
if (params.id !== thread.id) throw new Error("Thread agent tool cannot access another Thread");
|
|
85
|
+
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (THREAD_AGENT_REPO_METHODS.has(input.method)) {
|
|
89
|
+
const { cwd } = v.parse(v.object({ cwd: v.string() }), params);
|
|
90
|
+
|
|
91
|
+
if (resolve(cwd) !== resolve(thread.workspace.repoRoot))
|
|
92
|
+
throw new Error("Thread agent tool cannot access another repository");
|
|
93
|
+
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error("Thread agent tool API method is unavailable");
|
|
98
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { stepAgentTurn, type AgentTurn } from "./agent-turn";
|
|
3
|
+
|
|
4
|
+
test("an initialization stop settles before accepting a fresh turn", () => {
|
|
5
|
+
const starting = stepAgentTurn({ kind: "idle" }, "start");
|
|
6
|
+
const stopped = stepAgentTurn(starting, "stop");
|
|
7
|
+
const ready = stepAgentTurn(stopped, "ready");
|
|
8
|
+
|
|
9
|
+
expect(ready).toEqual({ kind: "idle" });
|
|
10
|
+
expect(stepAgentTurn(ready, "start")).toEqual({ kind: "starting", cancelled: false });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("an active stop waits for completion before accepting another turn", () => {
|
|
14
|
+
const running: AgentTurn = { kind: "running" };
|
|
15
|
+
const stopped = stepAgentTurn(running, "stop");
|
|
16
|
+
|
|
17
|
+
expect(stopped).toEqual({ kind: "stopping" });
|
|
18
|
+
expect(stepAgentTurn(stopped, "start")).toEqual(stopped);
|
|
19
|
+
expect(stepAgentTurn(stopped, "ready")).toEqual(stopped);
|
|
20
|
+
expect(stepAgentTurn(stepAgentTurn(stopped, "finished"), "start")).toEqual({
|
|
21
|
+
kind: "starting",
|
|
22
|
+
cancelled: false,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A harness turn distinguishes initialization cancellation from a running stop request. */
|
|
2
|
+
export type AgentTurn =
|
|
3
|
+
| { kind: "idle" }
|
|
4
|
+
| { kind: "starting"; cancelled: boolean }
|
|
5
|
+
| { kind: "running" }
|
|
6
|
+
| { kind: "stopping" };
|
|
7
|
+
|
|
8
|
+
/** Lifecycle events are independent of the harness wire protocol. */
|
|
9
|
+
export type AgentTurnEvent = "start" | "ready" | "stop" | "finished";
|
|
10
|
+
|
|
11
|
+
/** Start is accepted only at idle; readiness consumes an initialization cancellation. */
|
|
12
|
+
export function stepAgentTurn(state: AgentTurn, event: AgentTurnEvent): AgentTurn {
|
|
13
|
+
switch (event) {
|
|
14
|
+
case "start":
|
|
15
|
+
return state.kind === "idle" ? { kind: "starting", cancelled: false } : state;
|
|
16
|
+
case "ready":
|
|
17
|
+
return state.kind === "starting"
|
|
18
|
+
? state.cancelled
|
|
19
|
+
? { kind: "idle" }
|
|
20
|
+
: { kind: "running" }
|
|
21
|
+
: state;
|
|
22
|
+
case "stop":
|
|
23
|
+
if (state.kind === "starting") return { kind: "starting", cancelled: true };
|
|
24
|
+
|
|
25
|
+
return state.kind === "running" ? { kind: "stopping" } : state;
|
|
26
|
+
case "finished":
|
|
27
|
+
return { kind: "idle" };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pending stop requests suppress later tool and permission events from this turn. */
|
|
32
|
+
export function agentTurnCancelled(state: AgentTurn): boolean {
|
|
33
|
+
return state.kind === "stopping" || (state.kind === "starting" && state.cancelled);
|
|
34
|
+
}
|
package/src/capabilities.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type DaemonRole = "owner" | "collaborator" | "agent";
|
|
|
18
18
|
type Audience = "any" | "owner";
|
|
19
19
|
|
|
20
20
|
const PRIMITIVE_ROLES = {
|
|
21
|
+
"agent.configure": "owner",
|
|
22
|
+
"agent.get": "owner",
|
|
23
|
+
"agent.prompt": "owner",
|
|
24
|
+
"agent.cancel": "owner",
|
|
25
|
+
"agent.comment": "owner",
|
|
26
|
+
"agent.permission": "owner",
|
|
21
27
|
"daemon.ping": "any",
|
|
22
28
|
"daemon.hello": "any",
|
|
23
29
|
"daemon.shutdown": "owner",
|
package/src/client.test.ts
CHANGED
|
@@ -22,3 +22,16 @@ describe("daemonSpawnCommand", () => {
|
|
|
22
22
|
).toEqual(["/path/to/bun", "run", "/repo/packages/daemon/src/main.ts"]);
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
test("published daemon starts itself unless the CLI supplies its real entry path", () => {
|
|
27
|
+
const published = "file:///project/node_modules/@cueloop/daemon/src/client.ts";
|
|
28
|
+
|
|
29
|
+
expect(daemonSpawnCommand("bun", published, false)).toEqual([
|
|
30
|
+
"bun",
|
|
31
|
+
"run",
|
|
32
|
+
"/project/node_modules/@cueloop/daemon/src/main.ts",
|
|
33
|
+
]);
|
|
34
|
+
expect(
|
|
35
|
+
daemonSpawnCommand("bun", published, false, "/project/node_modules/cueloop/src/main.ts"),
|
|
36
|
+
).toEqual(["bun", "run", "/project/node_modules/cueloop/src/main.ts", "daemon", "--autostart"]);
|
|
37
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -58,6 +58,8 @@ import {
|
|
|
58
58
|
} from "./validate";
|
|
59
59
|
import type { WorkingTreeDiff } from "./working-tree";
|
|
60
60
|
import { DAEMON_VERSION } from "./version";
|
|
61
|
+
import { ThreadAgentSchema } from "./thread-agent-validation";
|
|
62
|
+
import type { AgentComment, ThreadAgentState } from "@cueloop/schema";
|
|
61
63
|
|
|
62
64
|
export type { EventFrame } from "./protocol";
|
|
63
65
|
|
|
@@ -101,6 +103,8 @@ export interface ThreadClient {
|
|
|
101
103
|
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
102
104
|
subscribe(): Promise<void>;
|
|
103
105
|
sessionGet(id: string): Promise<Thread>;
|
|
106
|
+
/** Create a local owner Thread; shared clients omit this capability. */
|
|
107
|
+
sessionCreate?(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
|
|
104
108
|
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
|
|
105
109
|
/** Add a comment; the primary annotate method. `sessionAnnotate` is the retained alias. */
|
|
106
110
|
sessionComment(
|
|
@@ -458,6 +462,44 @@ export class DaemonClient implements ThreadClient {
|
|
|
458
462
|
}
|
|
459
463
|
|
|
460
464
|
// ── typed primitives ─────────────────────────────
|
|
465
|
+
/** Read the agent transcript without starting a model request. */
|
|
466
|
+
agentGet(id: string): Promise<ThreadAgentState> {
|
|
467
|
+
return this.request("agent.get", { id }, ThreadAgentSchema);
|
|
468
|
+
}
|
|
469
|
+
/** Submit a question and optional selected passage to the daemon-owned agent. */
|
|
470
|
+
agentPrompt(params: {
|
|
471
|
+
id: string;
|
|
472
|
+
text: string;
|
|
473
|
+
context?: string;
|
|
474
|
+
retry?: string;
|
|
475
|
+
}): Promise<ThreadAgentState> {
|
|
476
|
+
return this.request("agent.prompt", params, ThreadAgentSchema);
|
|
477
|
+
}
|
|
478
|
+
/** Cancel the active turn; the daemon retains partial output. */
|
|
479
|
+
agentCancel(id: string): Promise<ThreadAgentState> {
|
|
480
|
+
return this.request("agent.cancel", { id }, ThreadAgentSchema);
|
|
481
|
+
}
|
|
482
|
+
/** Save quote-primary feedback on a finalized agent answer. */
|
|
483
|
+
agentComment(params: { id: string; comment: AgentComment }): Promise<ThreadAgentState> {
|
|
484
|
+
return this.request("agent.comment", params, ThreadAgentSchema);
|
|
485
|
+
}
|
|
486
|
+
/** Configure only an option advertised by the current harness. */
|
|
487
|
+
agentConfigure(params: {
|
|
488
|
+
id: string;
|
|
489
|
+
configId?: string;
|
|
490
|
+
value?: string;
|
|
491
|
+
}): Promise<ThreadAgentState> {
|
|
492
|
+
return this.request("agent.configure", params, ThreadAgentSchema);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Select an option from the active agent permission card. */
|
|
496
|
+
agentPermission(params: {
|
|
497
|
+
id: string;
|
|
498
|
+
requestId: string;
|
|
499
|
+
optionId: string;
|
|
500
|
+
}): Promise<ThreadAgentState> {
|
|
501
|
+
return this.request("agent.permission", params, ThreadAgentSchema);
|
|
502
|
+
}
|
|
461
503
|
ping(): Promise<{ pid: number }> {
|
|
462
504
|
return this.request("daemon.ping", {}, PingResultSchema);
|
|
463
505
|
}
|
|
@@ -731,7 +773,7 @@ export class DaemonClientError extends Error {
|
|
|
731
773
|
}
|
|
732
774
|
|
|
733
775
|
// A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
|
|
734
|
-
// unlike the never-exiting foreground daemon);
|
|
776
|
+
// unlike the never-exiting foreground daemon); source also uses the CLI composition root. In
|
|
735
777
|
// dev (CUELOOP_DEV_WATCH=1, source only) it runs under --watch so daemon-code edits
|
|
736
778
|
// reload the daemon without a manual restart - the version handshake only catches
|
|
737
779
|
// release upgrades, not same-version source changes.
|
|
@@ -739,14 +781,19 @@ export function daemonSpawnCommand(
|
|
|
739
781
|
execPath: string,
|
|
740
782
|
moduleUrl: string,
|
|
741
783
|
devWatch = process.env.CUELOOP_DEV_WATCH === "1",
|
|
784
|
+
entry = process.env.CUELOOP_DAEMON_ENTRY,
|
|
742
785
|
): string[] {
|
|
743
786
|
const compiled =
|
|
744
787
|
moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
|
|
745
788
|
|
|
746
789
|
if (compiled) return [execPath, "daemon", "--autostart"];
|
|
747
|
-
const mainPath = new URL("./main.ts", moduleUrl).pathname;
|
|
790
|
+
const mainPath = entry ?? new URL("./main.ts", moduleUrl).pathname;
|
|
791
|
+
|
|
792
|
+
const args = entry ? ["daemon", "--autostart"] : [];
|
|
748
793
|
|
|
749
|
-
return devWatch
|
|
794
|
+
return devWatch
|
|
795
|
+
? [execPath, "--watch", "run", mainPath, ...args]
|
|
796
|
+
: [execPath, "run", mainPath, ...args];
|
|
750
797
|
}
|
|
751
798
|
|
|
752
799
|
function spawnDaemon(home: string): string {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { writeHarnessDiagnostic } from "./harness-diagnostics";
|
|
6
|
+
|
|
7
|
+
test("diagnostic logs are private and bounded across repeated notices", () => {
|
|
8
|
+
const directory = mkdtempSync(join(tmpdir(), "cueloop-diagnostics-"));
|
|
9
|
+
const path = join(directory, "thread.diagnostics.ndjson");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
for (let i = 0; i < 100; i++)
|
|
13
|
+
writeHarnessDiagnostic(directory, "thread", {
|
|
14
|
+
kind: "diagnostic",
|
|
15
|
+
severity: "error",
|
|
16
|
+
source: "protocol",
|
|
17
|
+
title: "Advisory",
|
|
18
|
+
text: "x".repeat(32768),
|
|
19
|
+
});
|
|
20
|
+
expect(statSync(path).size).toBeLessThanOrEqual(256 * 1024);
|
|
21
|
+
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
22
|
+
expect(JSON.parse(readFileSync(path, "utf8").trim().split("\n")[0]!).text).toHaveLength(16384);
|
|
23
|
+
} finally {
|
|
24
|
+
rmSync(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AgentHarnessDiagnostic } from "@cueloop/schema";
|
|
4
|
+
|
|
5
|
+
/** Store harness diagnostics in a private, bounded log, outside Thread history and model context. */
|
|
6
|
+
export function writeHarnessDiagnostic(
|
|
7
|
+
directory: string,
|
|
8
|
+
threadId: string,
|
|
9
|
+
diagnostic: AgentHarnessDiagnostic,
|
|
10
|
+
): void {
|
|
11
|
+
const path = join(directory, `${encodeURIComponent(threadId)}.diagnostics.ndjson`);
|
|
12
|
+
const record =
|
|
13
|
+
JSON.stringify({
|
|
14
|
+
at: new Date().toISOString(),
|
|
15
|
+
...diagnostic,
|
|
16
|
+
severity: diagnostic.severity.slice(0, 128),
|
|
17
|
+
title: diagnostic.title.slice(0, 512),
|
|
18
|
+
text: diagnostic.text.slice(0, 16384),
|
|
19
|
+
}) + "\n";
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
23
|
+
if (existsSync(path) && statSync(path).size + Buffer.byteLength(record) > 256 * 1024)
|
|
24
|
+
writeFileSync(path, record, { mode: 0o600 });
|
|
25
|
+
else appendFileSync(path, record, { mode: 0o600 });
|
|
26
|
+
} catch {
|
|
27
|
+
// A diagnostic log failure must not change the agent turn outcome.
|
|
28
|
+
console.error("Thread agent diagnostic log could not be written");
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
statSync,
|
|
18
18
|
writeFileSync,
|
|
19
19
|
} from "node:fs";
|
|
20
|
+
import type { AgentHarnessTools } from "@cueloop/schema";
|
|
20
21
|
import { DaemonCore, type DaemonEvent } from "./api";
|
|
21
22
|
import { DaemonError } from "./errors";
|
|
22
23
|
import { DEFAULT_ROLE, roleAllowsMethod, type DaemonRole } from "./capabilities";
|
|
@@ -30,7 +31,9 @@ import {
|
|
|
30
31
|
} from "./protocol";
|
|
31
32
|
import { cueloopHome, lockPath, ownerTokenPath, pidPath, socketPath } from "./paths";
|
|
32
33
|
import { DAEMON_VERSION } from "./version";
|
|
33
|
-
import { randomBytes } from "node:crypto";
|
|
34
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
35
|
+
import { parseAgentToolInput, assertAgentToolScope } from "./agent-tools";
|
|
36
|
+
import { ThreadAgentManager, type ThreadAgentOptions } from "./thread-agent";
|
|
34
37
|
|
|
35
38
|
interface Connection {
|
|
36
39
|
/** The author a non-owner connection acts as, bound in the handshake. */
|
|
@@ -44,6 +47,8 @@ interface Connection {
|
|
|
44
47
|
type MethodHandler = (connection: Connection, request: Request) => Response["result"];
|
|
45
48
|
|
|
46
49
|
export interface DaemonOptions {
|
|
50
|
+
/** Explicit prototype configuration; commands cannot arrive from socket callers. */
|
|
51
|
+
threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter">;
|
|
47
52
|
home?: string;
|
|
48
53
|
/** Idle-exit delay; 0 disables (tests, foreground runs). */
|
|
49
54
|
idleExitMs?: number;
|
|
@@ -61,6 +66,7 @@ const HELD_HOMES = new Set<string>();
|
|
|
61
66
|
const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
62
67
|
|
|
63
68
|
export class DaemonServer {
|
|
69
|
+
private readonly threadAgent: ThreadAgentManager;
|
|
64
70
|
readonly core: DaemonCore;
|
|
65
71
|
readonly home: string;
|
|
66
72
|
private connections = new Set<Connection>();
|
|
@@ -80,6 +86,62 @@ export class DaemonServer {
|
|
|
80
86
|
this.version = options.version ?? DAEMON_VERSION;
|
|
81
87
|
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
82
88
|
this.core = new DaemonCore(this.home);
|
|
89
|
+
this.threadAgent = new ThreadAgentManager({
|
|
90
|
+
home: this.home,
|
|
91
|
+
enabled: options.threadAgent?.enabled ?? false,
|
|
92
|
+
enabledForThread: options.threadAgent?.enabledForThread,
|
|
93
|
+
adapter: options.threadAgent?.adapter,
|
|
94
|
+
getThread: (id) => this.core.sessionGet(id),
|
|
95
|
+
tools: (thread): AgentHarnessTools => ({
|
|
96
|
+
definitions: [
|
|
97
|
+
{
|
|
98
|
+
name: "send_message",
|
|
99
|
+
description:
|
|
100
|
+
"Return the Thread and its comments to the main session using the existing Send message action.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
id: { type: "string" },
|
|
105
|
+
outcome: { type: "string", enum: ["comment", "approved", "changes_requested"] },
|
|
106
|
+
summary: { type: "string" },
|
|
107
|
+
},
|
|
108
|
+
required: ["id", "outcome"],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "reply_to_comment",
|
|
113
|
+
description:
|
|
114
|
+
"Append a reply to an existing comment discussion, preserving its quote and Changes origin.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
id: { type: "string" },
|
|
119
|
+
commentId: { type: "string" },
|
|
120
|
+
body: { type: "string" },
|
|
121
|
+
},
|
|
122
|
+
required: ["id", "commentId", "body"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "cueloop_api",
|
|
127
|
+
description:
|
|
128
|
+
"Call a cueloop API operation on this Thread or its repository. Use session.get to inspect this Thread. Global harness, sharing, deletion and Thread creation operations are unavailable. Parameters follow the cueloop socket API.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: { method: { type: "string" }, params: { type: "object" } },
|
|
132
|
+
required: ["method", "params"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
call: async (name, input) => this.callAgentTool(thread.id, name, input),
|
|
137
|
+
}),
|
|
138
|
+
onChange: (id) => {
|
|
139
|
+
for (const connection of this.connections) {
|
|
140
|
+
if (connection.subscribed && connection.role === "owner")
|
|
141
|
+
connection.write(JSON.stringify({ event: "agent.updated", sessionId: id }) + "\n");
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
83
145
|
this.core.onEvent((event) => this.broadcast(event));
|
|
84
146
|
}
|
|
85
147
|
|
|
@@ -230,6 +292,7 @@ export class DaemonServer {
|
|
|
230
292
|
}
|
|
231
293
|
|
|
232
294
|
stop(): void {
|
|
295
|
+
this.threadAgent.dispose();
|
|
233
296
|
this.core.dispose();
|
|
234
297
|
if (this.lockFd === null) return;
|
|
235
298
|
this.server?.stop(true);
|
|
@@ -287,7 +350,81 @@ export class DaemonServer {
|
|
|
287
350
|
}
|
|
288
351
|
}
|
|
289
352
|
|
|
353
|
+
private async callAgentTool(threadId: string, name: string, input: string): Promise<string> {
|
|
354
|
+
const args = parseAgentToolInput(name, input);
|
|
355
|
+
const origin = this.core.sessionGet(threadId);
|
|
356
|
+
assertAgentToolScope(origin, args);
|
|
357
|
+
if (args.kind === "api" && args.method === "session.list") {
|
|
358
|
+
const { filter } = parseParams("session.list", args.params);
|
|
359
|
+
|
|
360
|
+
return JSON.stringify(!filter?.status || origin.status === filter.status ? [origin] : []);
|
|
361
|
+
}
|
|
362
|
+
if (args.kind === "reply") {
|
|
363
|
+
const thread = origin;
|
|
364
|
+
const comment = thread.annotations.find((entry) => entry.id === args.commentId);
|
|
365
|
+
if (!comment)
|
|
366
|
+
return JSON.stringify(this.threadAgent.reply(args.id, args.commentId, args.body));
|
|
367
|
+
const root =
|
|
368
|
+
thread.annotations.find((entry) => entry.id === (comment.replyTo ?? comment.id)) ?? comment;
|
|
369
|
+
|
|
370
|
+
return JSON.stringify(
|
|
371
|
+
this.core.sessionAnnotate(
|
|
372
|
+
args.id,
|
|
373
|
+
{
|
|
374
|
+
id: randomUUID(),
|
|
375
|
+
kind: "comment",
|
|
376
|
+
anchor: root.anchor,
|
|
377
|
+
target: root.target,
|
|
378
|
+
replyTo: root.id,
|
|
379
|
+
body: args.body,
|
|
380
|
+
author: "embedded-agent",
|
|
381
|
+
},
|
|
382
|
+
"Agent",
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const method = args.method;
|
|
387
|
+
if (!isKnownMethod(method)) throw new Error("Thread agent tool API method is unavailable");
|
|
388
|
+
|
|
389
|
+
return JSON.stringify(
|
|
390
|
+
await this.handlers[method](
|
|
391
|
+
{ role: "owner", subscribed: false, write: () => {} },
|
|
392
|
+
{ id: 0, method, params: args.params },
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
290
397
|
private readonly handlers: Record<MethodName, MethodHandler> = {
|
|
398
|
+
"agent.configure": (_connection, request) => {
|
|
399
|
+
const params = parseParams("agent.configure", request.params);
|
|
400
|
+
this.threadAgent.assertEnabled(params.id);
|
|
401
|
+
return this.threadAgent.configure(params);
|
|
402
|
+
},
|
|
403
|
+
"agent.get": (_connection, request) => {
|
|
404
|
+
const { id } = parseParams("agent.get", request.params);
|
|
405
|
+
this.threadAgent.assertEnabled(id);
|
|
406
|
+
return this.threadAgent.get(id);
|
|
407
|
+
},
|
|
408
|
+
"agent.prompt": (_connection, request) => {
|
|
409
|
+
const params = parseParams("agent.prompt", request.params);
|
|
410
|
+
this.threadAgent.assertEnabled(params.id);
|
|
411
|
+
return this.threadAgent.prompt(params);
|
|
412
|
+
},
|
|
413
|
+
"agent.cancel": (_connection, request) => {
|
|
414
|
+
const { id } = parseParams("agent.cancel", request.params);
|
|
415
|
+
this.threadAgent.assertEnabled(id);
|
|
416
|
+
return this.threadAgent.cancel(id);
|
|
417
|
+
},
|
|
418
|
+
"agent.comment": (_connection, request) => {
|
|
419
|
+
const params = parseParams("agent.comment", request.params);
|
|
420
|
+
this.threadAgent.assertEnabled(params.id);
|
|
421
|
+
return this.threadAgent.comment(params);
|
|
422
|
+
},
|
|
423
|
+
"agent.permission": (_connection, request) => {
|
|
424
|
+
const params = parseParams("agent.permission", request.params);
|
|
425
|
+
this.threadAgent.assertEnabled(params.id);
|
|
426
|
+
return this.threadAgent.permission(params);
|
|
427
|
+
},
|
|
291
428
|
"daemon.ping": () => ({ pid: process.pid, version: this.version }),
|
|
292
429
|
"daemon.hello": (connection, request) => {
|
|
293
430
|
const params = parseParams("daemon.hello", request.params);
|
|
@@ -340,11 +477,17 @@ export class DaemonServer {
|
|
|
340
477
|
"session.annotate": (_connection, request) => {
|
|
341
478
|
const params = parseParams("session.annotate", request.params);
|
|
342
479
|
|
|
480
|
+
if (this.threadAgent.isReadOnly(params.id, params.annotation.id))
|
|
481
|
+
throw new Error("Thread agent submitted comments are read-only");
|
|
482
|
+
|
|
343
483
|
return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
|
|
344
484
|
},
|
|
345
485
|
"session.comment": (_connection, request) => {
|
|
346
486
|
const params = parseParams("session.comment", request.params);
|
|
347
487
|
|
|
488
|
+
if (this.threadAgent.isReadOnly(params.id, params.annotation.id))
|
|
489
|
+
throw new Error("Thread agent submitted comments are read-only");
|
|
490
|
+
|
|
348
491
|
return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
|
|
349
492
|
},
|
|
350
493
|
"session.removeAnnotation": (connection, request) => {
|
|
@@ -358,6 +501,9 @@ export class DaemonServer {
|
|
|
358
501
|
);
|
|
359
502
|
}
|
|
360
503
|
|
|
504
|
+
if (this.threadAgent.isReadOnly(params.id, params.annotationId))
|
|
505
|
+
throw new Error("Thread agent submitted comments are read-only");
|
|
506
|
+
|
|
361
507
|
return this.core.sessionRemoveAnnotation(
|
|
362
508
|
params.id,
|
|
363
509
|
params.annotationId,
|
|
@@ -475,7 +621,10 @@ export class DaemonServer {
|
|
|
475
621
|
return this.core.sessionSetShares(params.id, params.shares);
|
|
476
622
|
},
|
|
477
623
|
"session.delete": (_connection, request) => {
|
|
478
|
-
|
|
624
|
+
const { id } = parseParams("session.delete", request.params);
|
|
625
|
+
|
|
626
|
+
this.core.sessionDelete(id);
|
|
627
|
+
this.threadAgent.remove(id);
|
|
479
628
|
|
|
480
629
|
return {};
|
|
481
630
|
},
|