@adecore/agents 0.0.1 → 0.17.0-beta.1
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/LICENSE +110 -0
- package/README.md +33 -0
- package/dist/async.d.ts +2 -0
- package/dist/async.js +31 -0
- package/dist/chat/attachment-store.d.ts +10 -0
- package/dist/chat/attachment-store.js +93 -0
- package/dist/chat/backend.d.ts +214 -0
- package/dist/chat/backend.js +1 -0
- package/dist/chat/background-work.d.ts +8 -0
- package/dist/chat/background-work.js +33 -0
- package/dist/chat/bookmark-store.d.ts +25 -0
- package/dist/chat/bookmark-store.js +141 -0
- package/dist/chat/chat-core.d.ts +311 -0
- package/dist/chat/chat-core.js +1025 -0
- package/dist/chat/chat-log.d.ts +33 -0
- package/dist/chat/chat-log.js +163 -0
- package/dist/chat/chat-process.d.ts +46 -0
- package/dist/chat/chat-process.js +190 -0
- package/dist/chat/chat-session.d.ts +209 -0
- package/dist/chat/chat-session.js +1511 -0
- package/dist/chat/chat-store.d.ts +37 -0
- package/dist/chat/chat-store.js +202 -0
- package/dist/chat/chat-title.d.ts +10 -0
- package/dist/chat/chat-title.js +74 -0
- package/dist/chat/claude-backend.d.ts +35 -0
- package/dist/chat/claude-backend.js +214 -0
- package/dist/chat/claude-protocol.d.ts +41 -0
- package/dist/chat/claude-protocol.js +642 -0
- package/dist/chat/claude-title.d.ts +11 -0
- package/dist/chat/claude-title.js +97 -0
- package/dist/chat/claude-transcript.d.ts +28 -0
- package/dist/chat/claude-transcript.js +211 -0
- package/dist/chat/codex-backend.d.ts +51 -0
- package/dist/chat/codex-backend.js +364 -0
- package/dist/chat/codex-protocol.d.ts +56 -0
- package/dist/chat/codex-protocol.js +767 -0
- package/dist/chat/codex-thread.d.ts +32 -0
- package/dist/chat/codex-thread.js +131 -0
- package/dist/chat/codex-transport.d.ts +42 -0
- package/dist/chat/codex-transport.js +188 -0
- package/dist/chat/composer-preferences.d.ts +15 -0
- package/dist/chat/composer-preferences.js +46 -0
- package/dist/chat/context-breakdown.d.ts +2 -0
- package/dist/chat/context-breakdown.js +74 -0
- package/dist/chat/delta-coalescer.d.ts +12 -0
- package/dist/chat/delta-coalescer.js +55 -0
- package/dist/chat/errors.d.ts +4 -0
- package/dist/chat/errors.js +4 -0
- package/dist/chat/fake-claude.d.ts +3 -0
- package/dist/chat/fake-claude.js +982 -0
- package/dist/chat/fake-cli.d.ts +25 -0
- package/dist/chat/fake-cli.js +157 -0
- package/dist/chat/fake-codex.d.ts +10 -0
- package/dist/chat/fake-codex.js +502 -0
- package/dist/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/dist/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/dist/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/dist/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/dist/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/dist/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/dist/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/dist/chat/input.d.ts +19 -0
- package/dist/chat/input.js +71 -0
- package/dist/chat/limit-resume.d.ts +11 -0
- package/dist/chat/limit-resume.js +68 -0
- package/dist/chat/projector.d.ts +60 -0
- package/dist/chat/projector.js +860 -0
- package/dist/chat/request-summary.d.ts +3 -0
- package/dist/chat/request-summary.js +143 -0
- package/dist/chat/subagent-projection.d.ts +8 -0
- package/dist/chat/subagent-projection.js +40 -0
- package/dist/chat/subagent-reader.d.ts +66 -0
- package/dist/chat/subagent-reader.js +396 -0
- package/dist/chat/subagent-settlement.d.ts +6 -0
- package/dist/chat/subagent-settlement.js +103 -0
- package/dist/chat/thread.d.ts +30 -0
- package/dist/chat/thread.js +147 -0
- package/dist/chat/wake-chat.d.ts +20 -0
- package/dist/chat/wake-chat.js +27 -0
- package/dist/client-sinks.d.ts +9 -0
- package/dist/client-sinks.js +34 -0
- package/dist/coded-error.d.ts +5 -0
- package/dist/coded-error.js +17 -0
- package/dist/context/argv.d.ts +11 -0
- package/dist/context/argv.js +58 -0
- package/dist/context/refusal.d.ts +9 -0
- package/dist/context/refusal.js +38 -0
- package/dist/context/verb.d.ts +85 -0
- package/dist/context/verb.js +233 -0
- package/dist/error-text.d.ts +5 -0
- package/dist/error-text.js +24 -0
- package/dist/events.d.ts +12 -0
- package/dist/events.js +1 -0
- package/dist/fs.d.ts +9 -0
- package/dist/fs.js +107 -0
- package/dist/host/agent-host.d.ts +28 -0
- package/dist/host/agent-host.js +105 -0
- package/dist/host/environment.d.ts +7 -0
- package/dist/host/environment.js +22 -0
- package/dist/host/handlers.d.ts +30 -0
- package/dist/host/handlers.js +111 -0
- package/dist/host/memory-port.d.ts +2 -0
- package/dist/host/memory-port.js +25 -0
- package/dist/host/wiring.d.ts +40 -0
- package/dist/host/wiring.js +120 -0
- package/dist/lineage.d.ts +44 -0
- package/dist/lineage.js +134 -0
- package/dist/messages/deliver-message.d.ts +21 -0
- package/dist/messages/deliver-message.js +42 -0
- package/dist/messages/deliver-notice.d.ts +25 -0
- package/dist/messages/deliver-notice.js +75 -0
- package/dist/messages/notice-notes.d.ts +15 -0
- package/dist/messages/notice-notes.js +26 -0
- package/dist/messages/notice-store.d.ts +30 -0
- package/dist/messages/notice-store.js +120 -0
- package/dist/modes.d.ts +11 -0
- package/dist/modes.js +28 -0
- package/dist/outbox/manual-clock.d.ts +10 -0
- package/dist/outbox/manual-clock.js +25 -0
- package/dist/outbox/outbox-worker.d.ts +44 -0
- package/dist/outbox/outbox-worker.js +173 -0
- package/dist/outbox/outbox.d.ts +37 -0
- package/dist/outbox/outbox.js +76 -0
- package/dist/providers/accounts/accounts.d.ts +20 -0
- package/dist/providers/accounts/accounts.js +158 -0
- package/dist/providers/accounts/launch.d.ts +17 -0
- package/dist/providers/accounts/launch.js +30 -0
- package/dist/providers/accounts/service.d.ts +87 -0
- package/dist/providers/accounts/service.js +538 -0
- package/dist/providers/accounts/shadow-home.d.ts +8 -0
- package/dist/providers/accounts/shadow-home.js +70 -0
- package/dist/providers/accounts/status.d.ts +12 -0
- package/dist/providers/accounts/status.js +85 -0
- package/dist/providers/accounts/store.d.ts +14 -0
- package/dist/providers/accounts/store.js +112 -0
- package/dist/providers/accounts/test-accounts.d.ts +12 -0
- package/dist/providers/accounts/test-accounts.js +38 -0
- package/dist/providers/accounts/variables.d.ts +27 -0
- package/dist/providers/accounts/variables.js +94 -0
- package/dist/providers/catalog.d.ts +17 -0
- package/dist/providers/catalog.js +76 -0
- package/dist/providers/claude-models.json +114 -0
- package/dist/providers/claude-provider.d.ts +4 -0
- package/dist/providers/claude-provider.js +29 -0
- package/dist/providers/claude.d.ts +15 -0
- package/dist/providers/claude.js +105 -0
- package/dist/providers/codex-models.json +135 -0
- package/dist/providers/codex-provider.d.ts +4 -0
- package/dist/providers/codex-provider.js +27 -0
- package/dist/providers/codex-rules.d.ts +9 -0
- package/dist/providers/codex-rules.js +35 -0
- package/dist/providers/codex.d.ts +14 -0
- package/dist/providers/codex.js +52 -0
- package/dist/providers/detect.d.ts +6 -0
- package/dist/providers/detect.js +30 -0
- package/dist/providers/provider.d.ts +25 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/registry.d.ts +28 -0
- package/dist/providers/registry.js +118 -0
- package/dist/record-directory.d.ts +27 -0
- package/dist/record-directory.js +114 -0
- package/dist/run-process.d.ts +13 -0
- package/dist/run-process.js +42 -0
- package/dist/serializer.d.ts +9 -0
- package/dist/serializer.js +34 -0
- package/dist/skills.d.ts +21 -0
- package/dist/skills.js +174 -0
- package/dist/tasks/background-limit.d.ts +16 -0
- package/dist/tasks/background-limit.js +55 -0
- package/dist/tasks/end-children.d.ts +34 -0
- package/dist/tasks/end-children.js +53 -0
- package/dist/tasks/give-task.d.ts +14 -0
- package/dist/tasks/give-task.js +33 -0
- package/dist/tasks/task-coordinator.d.ts +52 -0
- package/dist/tasks/task-coordinator.js +323 -0
- package/dist/tasks/task-store.d.ts +49 -0
- package/dist/tasks/task-store.js +197 -0
- package/dist/tasks/task-work.d.ts +47 -0
- package/dist/tasks/task-work.js +28 -0
- package/dist/tasks/waiting-child.d.ts +51 -0
- package/dist/tasks/waiting-child.js +143 -0
- package/dist/tasks/wake-parent.d.ts +19 -0
- package/dist/tasks/wake-parent.js +109 -0
- package/dist/tasks/wiring.d.ts +51 -0
- package/dist/tasks/wiring.js +151 -0
- package/dist/title-file.d.ts +2 -0
- package/dist/title-file.js +55 -0
- package/dist/usage/accounts.d.ts +15 -0
- package/dist/usage/accounts.js +64 -0
- package/dist/usage/aggregate.d.ts +12 -0
- package/dist/usage/aggregate.js +154 -0
- package/dist/usage/exchange.d.ts +16 -0
- package/dist/usage/exchange.js +84 -0
- package/dist/usage/index-file.d.ts +15 -0
- package/dist/usage/index-file.js +123 -0
- package/dist/usage/limits/monitor.d.ts +50 -0
- package/dist/usage/limits/monitor.js +179 -0
- package/dist/usage/limits/normalize.d.ts +21 -0
- package/dist/usage/limits/normalize.js +160 -0
- package/dist/usage/limits/probe.d.ts +9 -0
- package/dist/usage/limits/probe.js +113 -0
- package/dist/usage/prices-snapshot.json +1071 -0
- package/dist/usage/pricing.d.ts +34 -0
- package/dist/usage/pricing.js +186 -0
- package/dist/usage/projects.d.ts +17 -0
- package/dist/usage/projects.js +49 -0
- package/dist/usage/readers/claude.d.ts +3 -0
- package/dist/usage/readers/claude.js +59 -0
- package/dist/usage/readers/codex.d.ts +23 -0
- package/dist/usage/readers/codex.js +155 -0
- package/dist/usage/record.d.ts +15 -0
- package/dist/usage/record.js +39 -0
- package/dist/usage/roots.d.ts +12 -0
- package/dist/usage/roots.js +29 -0
- package/dist/usage/scanner.d.ts +30 -0
- package/dist/usage/scanner.js +234 -0
- package/dist/usage/usage-service.d.ts +35 -0
- package/dist/usage/usage-service.js +130 -0
- package/dist/watch-seam.d.ts +29 -0
- package/dist/watch-seam.js +78 -0
- package/dist/watch-test-helpers.d.ts +28 -0
- package/dist/watch-test-helpers.js +86 -0
- package/package.json +42 -3
- package/src/async.ts +40 -0
- package/src/chat/attachment-store.ts +108 -0
- package/src/chat/backend.ts +199 -0
- package/src/chat/background-work.ts +42 -0
- package/src/chat/bookmark-store.ts +166 -0
- package/src/chat/chat-core.ts +1228 -0
- package/src/chat/chat-log.ts +187 -0
- package/src/chat/chat-process.ts +237 -0
- package/src/chat/chat-session.ts +1723 -0
- package/src/chat/chat-store.ts +261 -0
- package/src/chat/chat-title.ts +90 -0
- package/src/chat/claude-backend.ts +235 -0
- package/src/chat/claude-protocol.ts +684 -0
- package/src/chat/claude-title.ts +106 -0
- package/src/chat/claude-transcript.ts +233 -0
- package/src/chat/codex-backend.ts +397 -0
- package/src/chat/codex-protocol.ts +872 -0
- package/src/chat/codex-thread.ts +162 -0
- package/src/chat/codex-transport.ts +224 -0
- package/src/chat/composer-preferences.ts +67 -0
- package/src/chat/context-breakdown.ts +80 -0
- package/src/chat/delta-coalescer.ts +62 -0
- package/src/chat/errors.ts +25 -0
- package/src/chat/fake-claude.ts +1006 -0
- package/src/chat/fake-cli.ts +199 -0
- package/src/chat/fake-codex.ts +534 -0
- package/src/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
- package/src/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
- package/src/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
- package/src/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
- package/src/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
- package/src/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
- package/src/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
- package/src/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
- package/src/chat/input.ts +89 -0
- package/src/chat/limit-resume.ts +74 -0
- package/src/chat/projector.ts +969 -0
- package/src/chat/request-summary.ts +168 -0
- package/src/chat/subagent-projection.ts +45 -0
- package/src/chat/subagent-reader.ts +469 -0
- package/src/chat/subagent-settlement.ts +111 -0
- package/src/chat/thread.ts +167 -0
- package/src/chat/wake-chat.ts +45 -0
- package/src/client-sinks.ts +39 -0
- package/src/coded-error.ts +18 -0
- package/src/context/argv.ts +61 -0
- package/src/context/refusal.ts +50 -0
- package/src/context/verb.ts +392 -0
- package/src/error-text.ts +28 -0
- package/src/events.ts +9 -0
- package/src/fs.ts +118 -0
- package/src/host/agent-host.ts +124 -0
- package/src/host/environment.ts +31 -0
- package/src/host/handlers.ts +142 -0
- package/src/host/memory-port.ts +27 -0
- package/src/host/wiring.ts +164 -0
- package/src/lineage.ts +159 -0
- package/src/messages/deliver-message.ts +61 -0
- package/src/messages/deliver-notice.ts +120 -0
- package/src/messages/notice-notes.ts +34 -0
- package/src/messages/notice-store.ts +142 -0
- package/src/modes.ts +38 -0
- package/src/outbox/manual-clock.ts +31 -0
- package/src/outbox/outbox-worker.ts +214 -0
- package/src/outbox/outbox.ts +106 -0
- package/src/providers/accounts/accounts.ts +192 -0
- package/src/providers/accounts/launch.ts +55 -0
- package/src/providers/accounts/service.ts +632 -0
- package/src/providers/accounts/shadow-home.ts +85 -0
- package/src/providers/accounts/status.ts +106 -0
- package/src/providers/accounts/store.ts +158 -0
- package/src/providers/accounts/test-accounts.ts +48 -0
- package/src/providers/accounts/variables.ts +132 -0
- package/src/providers/catalog.ts +90 -0
- package/src/providers/claude-models.json +114 -0
- package/src/providers/claude-provider.ts +32 -0
- package/src/providers/claude.ts +123 -0
- package/src/providers/codex-models.json +135 -0
- package/src/providers/codex-provider.ts +30 -0
- package/src/providers/codex-rules.ts +54 -0
- package/src/providers/codex.ts +69 -0
- package/src/providers/detect.ts +40 -0
- package/src/providers/provider.ts +46 -0
- package/src/providers/registry.ts +150 -0
- package/src/record-directory.ts +136 -0
- package/src/run-process.ts +61 -0
- package/src/serializer.ts +41 -0
- package/src/skills.ts +199 -0
- package/src/tasks/background-limit.ts +72 -0
- package/src/tasks/end-children.ts +100 -0
- package/src/tasks/give-task.ts +52 -0
- package/src/tasks/task-coordinator.ts +384 -0
- package/src/tasks/task-store.ts +236 -0
- package/src/tasks/task-work.ts +62 -0
- package/src/tasks/waiting-child.ts +203 -0
- package/src/tasks/wake-parent.ts +142 -0
- package/src/tasks/wiring.ts +231 -0
- package/src/title-file.ts +57 -0
- package/src/usage/accounts.ts +86 -0
- package/src/usage/aggregate.ts +220 -0
- package/src/usage/exchange.ts +93 -0
- package/src/usage/index-file.ts +178 -0
- package/src/usage/limits/monitor.ts +239 -0
- package/src/usage/limits/normalize.ts +188 -0
- package/src/usage/limits/probe.ts +120 -0
- package/src/usage/prices-snapshot.json +1071 -0
- package/src/usage/pricing.ts +236 -0
- package/src/usage/projects.ts +65 -0
- package/src/usage/readers/claude.ts +60 -0
- package/src/usage/readers/codex.ts +196 -0
- package/src/usage/record.ts +60 -0
- package/src/usage/roots.ts +39 -0
- package/src/usage/scanner.ts +265 -0
- package/src/usage/usage-service.ts +161 -0
- package/src/watch-seam.ts +111 -0
- package/src/watch-test-helpers.ts +102 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { TaskSchema, type Task, type TaskChangedEvent, type TaskResult } from '@adecore/agent-contracts';
|
|
4
|
+
import { ClientSinks } from '../client-sinks.ts';
|
|
5
|
+
import { RecordDirectory } from '../record-directory.ts';
|
|
6
|
+
|
|
7
|
+
export type TaskListener = (task: Task) => void;
|
|
8
|
+
|
|
9
|
+
/* What a client hears of a task: the whole of it, every time it is written. */
|
|
10
|
+
export interface TaskEvent {
|
|
11
|
+
event: 'task.changed';
|
|
12
|
+
payload: TaskChangedEvent;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TaskSink = (event: TaskEvent) => void;
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* What a chat asked of the nodes it opened with `--task`, one file per task under `<dataDir>/tasks`,
|
|
19
|
+
* beside the lineage and for the same reason: an agent with a shell in the project folder can rewrite
|
|
20
|
+
* the project's files, and waking the parent is a promise the host keeps. A settled task stays until
|
|
21
|
+
* its parent leaves the project, so the parent can still list what came of it.
|
|
22
|
+
*/
|
|
23
|
+
export class TaskStore {
|
|
24
|
+
readonly dir: string;
|
|
25
|
+
private readonly tasks: RecordDirectory<Task>;
|
|
26
|
+
private readonly listeners = new Set<TaskListener>();
|
|
27
|
+
private readonly sinks = new ClientSinks<TaskEvent>();
|
|
28
|
+
private readonly writes = new Set<Promise<void>>();
|
|
29
|
+
|
|
30
|
+
constructor(dataDir: string, options: { write?: (path: string, text: string) => Promise<void> } = {}) {
|
|
31
|
+
this.dir = join(dataDir, 'tasks');
|
|
32
|
+
this.tasks = new RecordDirectory({ dir: this.dir, schema: TaskSchema, idOf: (task) => task.id, ...options });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Reads what an earlier run of the host wrote down. Call before any verb or observer can ask. */
|
|
36
|
+
load(): Promise<void> {
|
|
37
|
+
return this.tasks.load();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Told about every task that is written, after it is on disk. */
|
|
41
|
+
onChange(listener: TaskListener): () => void {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => {
|
|
44
|
+
this.listeners.delete(listener);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Every socket hears every task: the edge and the header of a child are drawn from it. */
|
|
49
|
+
subscribe(clientId: string, sink: TaskSink): () => void {
|
|
50
|
+
return this.sinks.subscribe(clientId, sink);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async open(
|
|
54
|
+
record: { projectId: string; parentId: string; childId: string; title: string; prompt: string; batchId?: string; requiresTaskTurn?: boolean },
|
|
55
|
+
now: number
|
|
56
|
+
): Promise<Task> {
|
|
57
|
+
const task: Task = {
|
|
58
|
+
...record,
|
|
59
|
+
id: `task-${randomBytes(6).toString('hex')}`,
|
|
60
|
+
status: 'open',
|
|
61
|
+
result: null,
|
|
62
|
+
createdAt: now,
|
|
63
|
+
settledAt: null,
|
|
64
|
+
wake: 'pending'
|
|
65
|
+
};
|
|
66
|
+
await this.write(task);
|
|
67
|
+
return task;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
all(): Task[] {
|
|
71
|
+
return this.sorted();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async settled(): Promise<void> {
|
|
75
|
+
while (this.writes.size > 0) {
|
|
76
|
+
await Promise.all([...this.writes]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async holdBackground(id: string, background: NonNullable<Task['background']>): Promise<void> {
|
|
81
|
+
const task = this.get(id);
|
|
82
|
+
if (!task || task.status !== 'open' || JSON.stringify(task.background) === JSON.stringify(background)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await this.write({ ...task, background });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get(id: string): Task | undefined {
|
|
89
|
+
return this.tasks.get(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* The task a node is working on; a node has at most one, the one it was opened with. */
|
|
93
|
+
openFor(childId: string): Task | undefined {
|
|
94
|
+
return this.tasks.all().find((task) => task.childId === childId && task.status === 'open');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Every task this node gave or was given, oldest first. */
|
|
98
|
+
involving(nodeId: string): Task[] {
|
|
99
|
+
return this.sorted().filter((task) => task.parentId === nodeId || task.childId === nodeId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ofParent(parentId: string): Task[] {
|
|
103
|
+
return this.sorted().filter((task) => task.parentId === parentId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
ofProject(projectId: string): Task[] {
|
|
107
|
+
return this.sorted().filter((task) => task.projectId === projectId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* Ends an open task with its result; false when it was not open any more, which is how the first of
|
|
112
|
+
* `done`, the end of a turn and an exit wins. A cancelled task wakes nobody.
|
|
113
|
+
*/
|
|
114
|
+
async settle(id: string, status: 'done' | 'failed' | 'cancelled', result: TaskResult | null, now: number): Promise<Task | null> {
|
|
115
|
+
const task = this.tasks.get(id);
|
|
116
|
+
if (!task || task.status !== 'open') {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const { paused: _paused, ...open } = task;
|
|
120
|
+
const settled: Task = { ...open, status, result, settledAt: now, wake: status === 'cancelled' ? 'none' : task.wake };
|
|
121
|
+
await this.write(settled);
|
|
122
|
+
return settled;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Holds an open task while its child waits out a limit, or lets it go on with null; false when it was not open. */
|
|
126
|
+
async pause(id: string, paused: NonNullable<Task['paused']> | null): Promise<boolean> {
|
|
127
|
+
const task = this.tasks.get(id);
|
|
128
|
+
if (!task || task.status !== 'open') {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (JSON.stringify(task.paused ?? null) === JSON.stringify(paused)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const { paused: _was, ...rest } = task;
|
|
135
|
+
await this.write(paused === null ? rest : { ...rest, paused });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Cancels the open tasks of these children, with the reason as the result; a cancelled task wakes nobody. */
|
|
140
|
+
async cancelOpen(childIds: ReadonlySet<string>, reason: string, now: number): Promise<Task[]> {
|
|
141
|
+
const cancelled: Task[] = [];
|
|
142
|
+
for (const task of this.sorted()) {
|
|
143
|
+
if (task.status === 'open' && childIds.has(task.childId)) {
|
|
144
|
+
const settled = await this.settle(task.id, 'cancelled', { text: reason, source: 'exit', at: now }, now);
|
|
145
|
+
if (settled) {
|
|
146
|
+
cancelled.push(settled);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return cancelled;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async markWoken(ids: readonly string[]): Promise<void> {
|
|
154
|
+
for (const id of ids) {
|
|
155
|
+
const task = this.tasks.get(id);
|
|
156
|
+
if (task && task.wake === 'pending') {
|
|
157
|
+
await this.write({ ...task, wake: 'sent' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pendingWake(parentId: string): Task[] {
|
|
163
|
+
return this.ofParent(parentId).filter((task) => task.status !== 'open' && task.wake === 'pending');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* The part of `pendingWake` that may wake the parent now. A task of a `team --task` call is held back
|
|
168
|
+
* until every task of that call settled, so one team is one answer however far apart its roles finish.
|
|
169
|
+
*/
|
|
170
|
+
readyWake(parentId: string): Task[] {
|
|
171
|
+
const open = new Set(this.openBatches(parentId));
|
|
172
|
+
return this.pendingWake(parentId).filter((task) => task.batchId === undefined || !open.has(task.batchId));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* The batches of this parent with a task still open, oldest first. */
|
|
176
|
+
openBatches(parentId: string): string[] {
|
|
177
|
+
const ids = this.ofParent(parentId).flatMap((task) => (task.status === 'open' && task.batchId !== undefined ? [task.batchId] : []));
|
|
178
|
+
return [...new Set(ids)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* A parent nobody can wake any more: its settled tasks stop waiting for it. */
|
|
182
|
+
async dropWake(parentId: string): Promise<void> {
|
|
183
|
+
for (const task of this.ofParent(parentId)) {
|
|
184
|
+
if (task.wake === 'pending') {
|
|
185
|
+
await this.write({ ...task, wake: 'none' });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
* What this project wrote down for ids it no longer has. A task whose parent is gone has nobody to
|
|
192
|
+
* report to and goes; an open task whose child is gone is cancelled and stays, so its parent sees why.
|
|
193
|
+
*/
|
|
194
|
+
async prune(projectId: string, ids: ReadonlySet<string>, now: number): Promise<Task[]> {
|
|
195
|
+
const cancelled: Task[] = [];
|
|
196
|
+
for (const task of this.sorted()) {
|
|
197
|
+
if (task.projectId !== projectId) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!ids.has(task.parentId)) {
|
|
201
|
+
await this.tasks.remove(task.id);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!ids.has(task.childId) && task.status === 'open') {
|
|
205
|
+
const settled = await this.settle(task.id, 'cancelled', null, now);
|
|
206
|
+
if (settled) {
|
|
207
|
+
cancelled.push(settled);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return cancelled;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private sorted(): Task[] {
|
|
215
|
+
// A stable sort: tasks made in the same millisecond keep the order they were made in.
|
|
216
|
+
return this.tasks.all().sort((a, b) => a.createdAt - b.createdAt);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private write(task: Task): Promise<void> {
|
|
220
|
+
const write = this.writeNow(task);
|
|
221
|
+
this.writes.add(write);
|
|
222
|
+
void write.finally(() => this.writes.delete(write)).catch(() => undefined);
|
|
223
|
+
return write;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async writeNow(task: Task): Promise<void> {
|
|
227
|
+
// Only the write that is still the latest tells anyone: an older record never lands after it.
|
|
228
|
+
if (!(await this.tasks.write(task))) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const listener of this.listeners) {
|
|
232
|
+
listener(task);
|
|
233
|
+
}
|
|
234
|
+
this.sinks.emit({ event: 'task.changed', payload: { task } });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { OutboxEntryOf, OutboxWorkShape } from '../outbox/outbox.ts';
|
|
3
|
+
|
|
4
|
+
export const BackgroundLimitWorkSchema = z.object({
|
|
5
|
+
kind: z.literal('background-limit'),
|
|
6
|
+
// The target is the child; the task its background commands hold open, and what they were when the limit started, for a child whose process went since.
|
|
7
|
+
// `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
|
|
8
|
+
payload: z.object({ taskId: z.string().min(1), commands: z.array(z.string()), restarted: z.literal(true).optional() })
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const WakeParentWorkSchema = z.object({
|
|
12
|
+
kind: z.literal('wake-parent'),
|
|
13
|
+
// The target is the chat to wake; the task that settled is only what owed it, since a wake takes every settled task.
|
|
14
|
+
payload: z.object({ taskId: z.string().min(1) })
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const GiveTaskWorkSchema = z.object({
|
|
18
|
+
kind: z.literal('give-task'),
|
|
19
|
+
// The target is the agent the task went to; the record itself holds what it asks and who asked.
|
|
20
|
+
payload: z.object({ taskId: z.string().min(1) })
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const DeliverWaitingWorkSchema = z.object({
|
|
24
|
+
kind: z.literal('deliver-waiting'),
|
|
25
|
+
// The target is the chat that gave the child its task; the child waits on this request of its own.
|
|
26
|
+
payload: z.object({ childId: z.string().min(1), requestId: z.string().min(1) })
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type BackgroundLimitWork = z.infer<typeof BackgroundLimitWorkSchema>;
|
|
30
|
+
export type WakeParentWork = z.infer<typeof WakeParentWorkSchema>;
|
|
31
|
+
export type GiveTaskWork = z.infer<typeof GiveTaskWorkSchema>;
|
|
32
|
+
export type DeliverWaitingWork = z.infer<typeof DeliverWaitingWorkSchema>;
|
|
33
|
+
|
|
34
|
+
/* The work tasks owe, which a host puts in the discriminated union of its outbox beside its own kinds. */
|
|
35
|
+
export type TaskWork = BackgroundLimitWork | WakeParentWork | GiveTaskWork | DeliverWaitingWork;
|
|
36
|
+
|
|
37
|
+
export type BackgroundLimitEntry = OutboxEntryOf<BackgroundLimitWork>;
|
|
38
|
+
export type WakeParentEntry = OutboxEntryOf<WakeParentWork>;
|
|
39
|
+
export type GiveTaskEntry = OutboxEntryOf<GiveTaskWork>;
|
|
40
|
+
export type DeliverWaitingEntry = OutboxEntryOf<DeliverWaitingWork>;
|
|
41
|
+
|
|
42
|
+
/* An entry of whatever kinds a host owes, as tasks read its outbox. */
|
|
43
|
+
export type AnyOutboxEntry = OutboxEntryOf<OutboxWorkShape>;
|
|
44
|
+
|
|
45
|
+
/* The outbox of a host as tasks use it, whatever else the host owes in it. */
|
|
46
|
+
export interface TaskOutbox {
|
|
47
|
+
/* Every entry the host owes, of any kind, oldest first. */
|
|
48
|
+
list(): readonly AnyOutboxEntry[];
|
|
49
|
+
/* `notBefore` is when the work is due; now without it. */
|
|
50
|
+
enqueue(projectId: string, target: string, work: TaskWork, notBefore?: number): Promise<void>;
|
|
51
|
+
remove(id: string): Promise<void>;
|
|
52
|
+
/* The work of this target that answered `wait` may go on now. */
|
|
53
|
+
wake(target: string): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isBackgroundLimit(entry: AnyOutboxEntry): entry is BackgroundLimitEntry {
|
|
57
|
+
return entry.kind === 'background-limit';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isGiveTask(entry: AnyOutboxEntry): entry is GiveTaskEntry {
|
|
61
|
+
return entry.kind === 'give-task';
|
|
62
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { clipText, type ChatApprovalItem, type ChatItem, type ChatQuestionItem, type Task } from '@adecore/agent-contracts';
|
|
2
|
+
import type { ChatCore } from '../chat/chat-core.ts';
|
|
3
|
+
import { errorText } from '../error-text.ts';
|
|
4
|
+
import type { AgentEvent } from '../events.ts';
|
|
5
|
+
import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
|
|
6
|
+
import type { DeliverWaitingEntry, TaskWork } from './task-work.ts';
|
|
7
|
+
|
|
8
|
+
/* What a child waits on: an answer anyone may give, or an approval only a person gives. */
|
|
9
|
+
export type WaitingRequest = ChatQuestionItem | ChatApprovalItem;
|
|
10
|
+
|
|
11
|
+
export function isPendingRequest(item: ChatItem | null | undefined): item is WaitingRequest {
|
|
12
|
+
return (item?.kind === 'question' && item.state === 'pending') || (item?.kind === 'approval' && item.decision === 'pending');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* What the chats of agent nodes ask and wait on, which a verb such as `answer` reaches. */
|
|
16
|
+
export interface ChatRequests {
|
|
17
|
+
/* The one under this request id, in whatever state; null when the chat holds none, or nobody loaded it. */
|
|
18
|
+
request(nodeId: string, requestId: string): WaitingRequest | null;
|
|
19
|
+
/* Those it waits on now, oldest first. */
|
|
20
|
+
waiting(nodeId: string): WaitingRequest[];
|
|
21
|
+
/* Answers a pending question the way a person's `chat.answer` does; false when it no longer waits. */
|
|
22
|
+
answer(nodeId: string, requestId: string, answers: Record<string, string>): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Read off the chats the host has loaded: a chat nobody loaded since a restart waits on nothing. */
|
|
26
|
+
export function chatRequests(chats: Pick<ChatCore, 'get' | 'answer'>): ChatRequests {
|
|
27
|
+
return {
|
|
28
|
+
request: (nodeId, requestId) => {
|
|
29
|
+
// The projector writes each under the kind it is.
|
|
30
|
+
const thread = chats.get(nodeId)?.thread;
|
|
31
|
+
const item = thread?.get(`question-${requestId}`) ?? thread?.get(`approval-${requestId}`);
|
|
32
|
+
return item?.kind === 'question' || item?.kind === 'approval' ? item : null;
|
|
33
|
+
},
|
|
34
|
+
waiting: (nodeId) => (chats.get(nodeId)?.thread.list() ?? []).filter(isPendingRequest),
|
|
35
|
+
answer: (nodeId, requestId, answers) => {
|
|
36
|
+
try {
|
|
37
|
+
chats.answer(nodeId, requestId, answers);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* The id of the note one request of one child leaves in its parent, so delivering it twice leaves one. */
|
|
47
|
+
export function waitingNoteId(childId: string, requestId: string): string {
|
|
48
|
+
return `waiting-${childId}-${requestId}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* How long a request may wait before its parent hears of it. A person watching the child settles one
|
|
53
|
+
* within seconds (1.5 to 7 seconds in a run of Claude Code 2.1.282), and a note about that is stale
|
|
54
|
+
* before the parent reads it.
|
|
55
|
+
*/
|
|
56
|
+
export const WAITING_GRACE_MS = 15_000;
|
|
57
|
+
|
|
58
|
+
export interface WaitingObserverDeps {
|
|
59
|
+
openTask(childId: string): Task | undefined;
|
|
60
|
+
enqueue(projectId: string, target: string, work: TaskWork, notBefore: number): Promise<void>;
|
|
61
|
+
now(): number;
|
|
62
|
+
log?(message: string): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* Notes that a child with an open task asked something, and owes its parent one note in the outbox,
|
|
67
|
+
* due once the request waited out `WAITING_GRACE_MS`. It never writes the note itself, since it runs
|
|
68
|
+
* inside the broadcast of the child it heard, and it never wakes the parent: a finished task and a
|
|
69
|
+
* message are the only things that wake a chat.
|
|
70
|
+
*/
|
|
71
|
+
export class WaitingObserver {
|
|
72
|
+
private readonly deps: WaitingObserverDeps;
|
|
73
|
+
// A pending item is upserted again as it changes; one entry per request is enough, and the handler is idempotent after a restart.
|
|
74
|
+
private readonly owed = new Set<string>();
|
|
75
|
+
private stopped = false;
|
|
76
|
+
|
|
77
|
+
constructor(deps: WaitingObserverDeps) {
|
|
78
|
+
this.deps = deps;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stop(): void {
|
|
82
|
+
this.stopped = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* For `chats.observe()`. */
|
|
86
|
+
chatEvent(event: AgentEvent): void {
|
|
87
|
+
if (this.stopped || event.event !== 'chat.event') {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { chatId, event: chatEvent } = event.payload;
|
|
91
|
+
if (chatEvent.type !== 'item' || !isPendingRequest(chatEvent.item)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const task = this.deps.openTask(chatId);
|
|
95
|
+
const key = `${chatId}\n${chatEvent.item.requestId}`;
|
|
96
|
+
if (task === undefined || this.owed.has(key)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.owed.add(key);
|
|
100
|
+
void this.deps
|
|
101
|
+
.enqueue(
|
|
102
|
+
task.projectId,
|
|
103
|
+
task.parentId,
|
|
104
|
+
{ kind: 'deliver-waiting', payload: { childId: chatId, requestId: chatEvent.item.requestId } },
|
|
105
|
+
this.deps.now() + WAITING_GRACE_MS
|
|
106
|
+
)
|
|
107
|
+
.catch((e: unknown) => (this.deps.log ?? console.error)(`Owing a note about ${chatId} failed: ${errorText(e)}`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function quoted(text: string): string {
|
|
112
|
+
return JSON.stringify(text.replace(/\s+/g, ' ').trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* The one line a note shows before "Show more": a question longer than that is read in the node. */
|
|
116
|
+
const NOTE_QUESTION_LENGTH = 200;
|
|
117
|
+
|
|
118
|
+
function quotedShort(text: string): string {
|
|
119
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
120
|
+
return JSON.stringify(line.length > NOTE_QUESTION_LENGTH ? `${clipText(line, NOTE_QUESTION_LENGTH - 1)}…` : line);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* One question as the parent's CLI reads it, with the choices it may pick from. */
|
|
124
|
+
function questionLine(question: ChatQuestionItem['questions'][number]): string {
|
|
125
|
+
const choices = question.choices.map((choice) => (choice.description === '' ? choice.label : `${choice.label} (${choice.description})`));
|
|
126
|
+
const how =
|
|
127
|
+
choices.length === 0
|
|
128
|
+
? 'answer in your own words'
|
|
129
|
+
: `choices: ${choices.join(' | ')}; ${question.multiSelect ? 'pick one or more' : 'pick one'}, or answer in your own words`;
|
|
130
|
+
return `- question ${question.id}: ${quoted(question.question)} (${how})`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* The words a host speaks in: its name, at the head of what a parent's CLI hears, and the command an
|
|
135
|
+
* agent types, whose `answer <child> <request> --answer A` or `--answers JSON` answers a child's question.
|
|
136
|
+
*/
|
|
137
|
+
export interface WaitingWords {
|
|
138
|
+
app: string;
|
|
139
|
+
cli: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* The call that answers it, in the shape the verb takes for this many questions. */
|
|
143
|
+
function answerCall(cli: string, childId: string, item: ChatQuestionItem): string {
|
|
144
|
+
if (item.questions.length === 1) {
|
|
145
|
+
const multi = item.questions[0]!.multiSelect ? ', several labels in one --answer separated by a comma' : '';
|
|
146
|
+
return `${cli} answer ${childId} ${item.requestId} --answer '<a choice label as written, or your own words>'${multi}`;
|
|
147
|
+
}
|
|
148
|
+
const shape = Object.fromEntries(item.questions.map((question) => [question.id, '...']));
|
|
149
|
+
return `${cli} answer ${childId} ${item.requestId} --answers '${JSON.stringify(shape)}', one answer per question id`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* What the parent's thread shows and what its CLI hears, from one request of one child. The note's
|
|
154
|
+
* first line is what a person sees at a glance; under it, what the CLI hears in front of its next turn.
|
|
155
|
+
*/
|
|
156
|
+
export function waitingTexts(child: { id: string; title: string }, item: WaitingRequest, words: WaitingWords): { note: string; preamble: string } {
|
|
157
|
+
const who = `${child.title} (node ${child.id})`;
|
|
158
|
+
if (item.kind === 'approval') {
|
|
159
|
+
const what = item.description === null ? item.toolName : `${item.toolName}: ${item.description}`;
|
|
160
|
+
return {
|
|
161
|
+
note: `${who} waits for a person to approve ${what}`,
|
|
162
|
+
preamble: [
|
|
163
|
+
`${words.app}: ${who}, an agent you gave a task, waits for a person to approve ${what} (request ${item.requestId}). This did not wake you.`,
|
|
164
|
+
'Only a person answers an approval; there is no verb for it. If it holds up your plan, tell the person.'
|
|
165
|
+
].join('\n')
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const asked = item.questions.length === 1 ? quotedShort(item.questions[0]!.question) : `${item.questions.length} questions`;
|
|
169
|
+
const how = [
|
|
170
|
+
...item.questions.map(questionLine),
|
|
171
|
+
`A person can answer it in that node, and this chat with ${answerCall(words.cli, child.id, item)}. Whoever answers first wins; the verb refuses a question that was answered already.`
|
|
172
|
+
];
|
|
173
|
+
return {
|
|
174
|
+
note: [`${who} waits for an answer to ${asked}`, '', ...how].join('\n'),
|
|
175
|
+
preamble: [
|
|
176
|
+
`${words.app}: ${who}, an agent you gave a task, asked a question and waits for an answer (request ${item.requestId}). This did not wake you.`,
|
|
177
|
+
...how
|
|
178
|
+
].join('\n')
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DeliverWaitingDeps {
|
|
183
|
+
request: ChatRequests['request'];
|
|
184
|
+
titleFor(nodeId: string): string | null;
|
|
185
|
+
deliver(chatId: string, delivery: { noteId: string; note: string; preamble: string }): Promise<boolean>;
|
|
186
|
+
words: WaitingWords;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* Leaves the note in the parent, once. A request that no longer waits (answered, allowed, taken back
|
|
191
|
+
* by its CLI, or cancelled by a restart) is news about nothing, so it says nothing.
|
|
192
|
+
*/
|
|
193
|
+
export function deliverWaitingHandler(deps: DeliverWaitingDeps) {
|
|
194
|
+
return async (entry: DeliverWaitingEntry): Promise<OutboxOutcome> => {
|
|
195
|
+
const { childId, requestId } = entry.payload;
|
|
196
|
+
const item = deps.request(childId, requestId);
|
|
197
|
+
if (!isPendingRequest(item)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const texts = waitingTexts({ id: childId, title: deps.titleFor(childId) ?? childId }, item, deps.words);
|
|
201
|
+
await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), ...texts });
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Task } from '@adecore/agent-contracts';
|
|
2
|
+
import type { WakeChat } from '../chat/wake-chat.ts';
|
|
3
|
+
import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
|
|
4
|
+
import type { TaskStore } from './task-store.ts';
|
|
5
|
+
import type { TaskWork, WakeParentEntry } from './task-work.ts';
|
|
6
|
+
|
|
7
|
+
/* What the parent reads of one result; the rest stays with the child, whose id is right beside it. */
|
|
8
|
+
export const RESULT_PREVIEW_BYTES = 8 * 1024;
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
/* The first `bytes` of a text without cutting a character in half, and whether anything was left off. */
|
|
14
|
+
function cutAt(text: string, bytes: number): { text: string; cut: boolean } {
|
|
15
|
+
const encoded = encoder.encode(text);
|
|
16
|
+
if (encoded.length <= bytes) {
|
|
17
|
+
return { text, cut: false };
|
|
18
|
+
}
|
|
19
|
+
return { text: decoder.decode(encoded.slice(0, bytes)).replace(/\uFFFD$/, ''), cut: true };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* The prompt a woken chat's CLI gets. Every task is named with its child's id, so the agent can go
|
|
24
|
+
* on with that node (read it, notify it) without asking which one it was. `restOf` says where the rest
|
|
25
|
+
* of a result that was cut is to be read.
|
|
26
|
+
*/
|
|
27
|
+
export function wakePrompt(tasks: readonly Task[], teamsOut = 0, restOf?: (childId: string) => string): string {
|
|
28
|
+
const head = tasks.length === 1 ? 'A task you gave has settled. Its result:' : `${tasks.length} tasks you gave have settled. Their results:`;
|
|
29
|
+
const sections = tasks.map((task) => {
|
|
30
|
+
const { text, cut } = cutAt(task.result?.text ?? '', RESULT_PREVIEW_BYTES);
|
|
31
|
+
const lines = [`## ${task.title} (node ${task.childId}, task ${task.id}): ${task.status}`, '', text === '' ? '(no result text)' : text];
|
|
32
|
+
if (cut) {
|
|
33
|
+
const rest = restOf === undefined ? '' : ` ${restOf(task.childId)}`;
|
|
34
|
+
lines.push('', `[The result was cut at ${RESULT_PREVIEW_BYTES / 1024} KiB.${rest}]`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
});
|
|
38
|
+
const together = tasks.some((task) => task.batchId !== undefined)
|
|
39
|
+
? ['The tasks of one team call come in together, once every one of them has settled.']
|
|
40
|
+
: [];
|
|
41
|
+
const out =
|
|
42
|
+
teamsOut === 0
|
|
43
|
+
? []
|
|
44
|
+
: [
|
|
45
|
+
`${teamsOut === 1 ? 'A team you gave is' : `${teamsOut} teams you gave are`} still out: you are woken with all of a team's results once its last task settles.`
|
|
46
|
+
];
|
|
47
|
+
return [head, ...sections, ...together, ...out].join('\n\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The label of the turn a wake opens: what a person reads above it in the thread. */
|
|
51
|
+
export function wakeLabel(tasks: readonly Task[]): string {
|
|
52
|
+
return tasks.map((task) => task.title).join(', ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function wakeNote(tasks: readonly Task[]): string {
|
|
56
|
+
return `Woken by ${tasks.length} finished ${tasks.length === 1 ? 'task' : 'tasks'}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WakeParentDeps {
|
|
60
|
+
tasks: Pick<TaskStore, 'pendingWake' | 'readyWake' | 'openBatches' | 'markWoken' | 'dropWake'>;
|
|
61
|
+
/* The chat as it is now, loading it from disk when nobody has; null for a chat that has no thread any more. */
|
|
62
|
+
chat(chatId: string): Promise<WakeChat | null>;
|
|
63
|
+
/* Where the rest of a result that was cut is read, after the line that says so. */
|
|
64
|
+
restOf?: (childId: string) => string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* CLI acceptance lives in the saved turn before the task store consumes its result, so a restart
|
|
69
|
+
* between those writes can finish delivery without opening another turn.
|
|
70
|
+
*/
|
|
71
|
+
export function wakeParentHandler(deps: WakeParentDeps) {
|
|
72
|
+
return async (entry: WakeParentEntry): Promise<OutboxOutcome> => {
|
|
73
|
+
const parentId = entry.target;
|
|
74
|
+
const pending = deps.tasks.pendingWake(parentId);
|
|
75
|
+
if (pending.length === 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const held = (): OutboxOutcome => (deps.tasks.readyWake(parentId).length < deps.tasks.pendingWake(parentId).length ? 'wait' : undefined);
|
|
79
|
+
// Nothing to say yet, so the chat is not loaded from disk for it.
|
|
80
|
+
if (deps.tasks.readyWake(parentId).length === 0) {
|
|
81
|
+
return held();
|
|
82
|
+
}
|
|
83
|
+
const chat = await deps.chat(parentId);
|
|
84
|
+
if (chat === null) {
|
|
85
|
+
await deps.tasks.dropWake(parentId);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const items = chat.items();
|
|
89
|
+
const named = new Set(items.flatMap((item) => (item.kind === 'turn' && !item.deliveryPending ? (item.taskIds ?? []) : [])));
|
|
90
|
+
const already = deps.tasks.pendingWake(parentId).filter((task) => named.has(task.id));
|
|
91
|
+
if (already.length > 0) {
|
|
92
|
+
await chat.persist?.();
|
|
93
|
+
await deps.tasks.markWoken(already.map((task) => task.id));
|
|
94
|
+
}
|
|
95
|
+
const tasks = deps.tasks.readyWake(parentId).filter((task) => !named.has(task.id));
|
|
96
|
+
if (tasks.length === 0) {
|
|
97
|
+
return held();
|
|
98
|
+
}
|
|
99
|
+
// An unacknowledged attempt waits for a person's next turn, rather than retrying on every chat event.
|
|
100
|
+
const blocked = items.some(
|
|
101
|
+
(item, index) =>
|
|
102
|
+
item.kind === 'turn' &&
|
|
103
|
+
item.deliveryPending &&
|
|
104
|
+
item.taskIds?.some((id) => tasks.some((task) => task.id === id)) &&
|
|
105
|
+
(item.state === 'running' || !items.slice(index + 1).some((later) => later.kind === 'turn' && later.origin !== 'agent'))
|
|
106
|
+
);
|
|
107
|
+
if (blocked) {
|
|
108
|
+
return 'wait';
|
|
109
|
+
}
|
|
110
|
+
const teamsOut = deps.tasks.openBatches(parentId).length;
|
|
111
|
+
if (
|
|
112
|
+
!chat.wake({
|
|
113
|
+
text: wakePrompt(tasks, teamsOut, deps.restOf),
|
|
114
|
+
label: wakeLabel(tasks),
|
|
115
|
+
note: wakeNote(tasks),
|
|
116
|
+
taskIds: tasks.map((task) => task.id)
|
|
117
|
+
})
|
|
118
|
+
) {
|
|
119
|
+
return 'wait';
|
|
120
|
+
}
|
|
121
|
+
await chat.persist?.();
|
|
122
|
+
if (
|
|
123
|
+
tasks.some((task) => {
|
|
124
|
+
const turn = chat.items().findLast((item) => item.kind === 'turn' && item.taskIds?.includes(task.id));
|
|
125
|
+
return turn?.kind === 'turn' && turn.deliveryPending;
|
|
126
|
+
})
|
|
127
|
+
) {
|
|
128
|
+
return 'wait';
|
|
129
|
+
}
|
|
130
|
+
await deps.tasks.markWoken(tasks.map((task) => task.id));
|
|
131
|
+
return held();
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface OweWakeDeps {
|
|
136
|
+
enqueue(projectId: string, target: string, work: TaskWork): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* A settled task owes its parent a wake; one entry per task, and the first to run takes every task there is. */
|
|
140
|
+
export function oweWake(deps: OweWakeDeps, task: Task): Promise<void> {
|
|
141
|
+
return deps.enqueue(task.projectId, task.parentId, { kind: 'wake-parent', payload: { taskId: task.id } });
|
|
142
|
+
}
|