@adecore/agents 0.21.1 → 0.21.2
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/async.js +3 -8
- package/dist/chat/chat-core.d.ts +2 -2
- package/dist/chat/chat-core.js +4 -4
- package/dist/chat/chat-log.js +2 -2
- package/dist/chat/chat-store.js +4 -7
- package/dist/chat/chat-title.js +4 -8
- package/dist/chat/claude-backend.d.ts +1 -0
- package/dist/chat/claude-backend.js +8 -6
- package/dist/chat/claude-title.js +1 -1
- package/dist/chat/codex-backend.d.ts +2 -2
- package/dist/chat/codex-backend.js +5 -8
- package/dist/chat/codex-protocol.js +1 -1
- package/dist/chat/codex-thread.js +4 -5
- package/dist/chat/composer-preferences.js +2 -3
- package/dist/chat/limit-resume.js +2 -7
- package/dist/chat/visual-store.js +8 -6
- package/dist/context/refusal.js +3 -7
- package/dist/context/verb.js +1 -5
- package/dist/host/agent-host.js +2 -2
- package/dist/host/handlers.js +0 -1
- package/dist/host/wiring.js +1 -1
- package/dist/lineage.js +2 -3
- package/dist/messages/deliver-message.js +6 -11
- package/dist/messages/deliver-notice.js +8 -12
- package/dist/messages/notice-store.js +3 -5
- package/dist/outbox/outbox-worker.js +5 -2
- package/dist/providers/accounts/service.d.ts +2 -1
- package/dist/providers/accounts/service.js +11 -13
- package/dist/providers/claude.js +0 -1
- package/dist/providers/detect.js +2 -10
- package/dist/record-directory.js +0 -1
- package/dist/tasks/task-coordinator.d.ts +1 -0
- package/dist/tasks/task-coordinator.js +34 -34
- package/dist/tasks/task-store.js +1 -1
- package/dist/tasks/task-work.js +2 -2
- package/dist/tasks/waiting-child.js +5 -3
- package/dist/tasks/wake-parent.js +15 -10
- package/dist/tasks/wiring.js +32 -37
- package/dist/usage/aggregate.js +62 -46
- package/dist/usage/exchange.js +1 -1
- package/dist/usage/limits/monitor.d.ts +2 -2
- package/dist/usage/limits/monitor.js +2 -2
- package/dist/usage/limits/probe.js +1 -1
- package/dist/usage/pricing.js +1 -1
- package/dist/usage/roots.js +1 -2
- package/dist/usage/scanner.js +7 -9
- package/dist/usage/usage-service.d.ts +1 -2
- package/dist/usage/usage-service.js +3 -6
- package/package.json +3 -3
- package/src/async.ts +3 -9
- package/src/chat/chat-core.ts +4 -4
- package/src/chat/chat-log.ts +2 -2
- package/src/chat/chat-store.ts +4 -8
- package/src/chat/chat-title.ts +4 -8
- package/src/chat/claude-backend.ts +9 -6
- package/src/chat/claude-title.ts +1 -1
- package/src/chat/claude-transcript.ts +1 -1
- package/src/chat/codex-backend.ts +5 -8
- package/src/chat/codex-protocol.ts +1 -1
- package/src/chat/codex-thread.ts +6 -5
- package/src/chat/composer-preferences.ts +2 -3
- package/src/chat/limit-resume.ts +2 -7
- package/src/chat/thread.ts +1 -1
- package/src/chat/visual-store.ts +9 -6
- package/src/chat/wake-chat.ts +4 -1
- package/src/context/refusal.ts +3 -7
- package/src/context/verb.ts +1 -5
- package/src/host/agent-host.ts +2 -2
- package/src/host/handlers.ts +0 -1
- package/src/host/wiring.ts +1 -1
- package/src/lineage.ts +2 -3
- package/src/messages/deliver-message.ts +6 -11
- package/src/messages/deliver-notice.ts +8 -12
- package/src/messages/notice-store.ts +3 -5
- package/src/outbox/outbox-worker.ts +5 -2
- package/src/providers/accounts/service.ts +12 -13
- package/src/providers/accounts/variables.ts +1 -0
- package/src/providers/claude.ts +0 -1
- package/src/providers/detect.ts +2 -11
- package/src/record-directory.ts +0 -1
- package/src/skills.ts +0 -1
- package/src/tasks/task-coordinator.ts +35 -33
- package/src/tasks/task-store.ts +1 -1
- package/src/tasks/task-work.ts +2 -2
- package/src/tasks/waiting-child.ts +6 -3
- package/src/tasks/wake-parent.ts +21 -16
- package/src/tasks/wiring.ts +33 -41
- package/src/usage/aggregate.ts +80 -50
- package/src/usage/exchange.ts +1 -1
- package/src/usage/limits/monitor.ts +4 -4
- package/src/usage/limits/probe.ts +2 -2
- package/src/usage/pricing.ts +1 -1
- package/src/usage/roots.ts +1 -2
- package/src/usage/scanner.ts +8 -10
- package/src/usage/usage-service.ts +5 -9
|
@@ -24,13 +24,11 @@ export interface DeliverMessageDeps {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/*
|
|
27
|
-
* Opens the turn a message earns a chat. The messages
|
|
28
|
-
*
|
|
29
|
-
* own prompt reads them through, so one message is heard once whichever of the two opens the turn.
|
|
27
|
+
* Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
|
|
28
|
+
* takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
|
|
30
29
|
*
|
|
31
|
-
* One attempt, never `wait
|
|
32
|
-
*
|
|
33
|
-
* make the host start turns nobody asked for long after the news was worth anything.
|
|
30
|
+
* One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
|
|
31
|
+
* holding the entry would start turns nobody asked for long after the news mattered.
|
|
34
32
|
*/
|
|
35
33
|
export function deliverMessageHandler(deps: DeliverMessageDeps) {
|
|
36
34
|
return async (entry: DeliverMessageEntry): Promise<OutboxOutcome> => {
|
|
@@ -46,11 +44,8 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
|
|
|
46
44
|
if (chat === null) {
|
|
47
45
|
return;
|
|
48
46
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* landed (`showNotices`), so anything the turn adds about it only says the same thing again;
|
|
52
|
-
* the label on the turn already names who it came from.
|
|
53
|
-
*/
|
|
47
|
+
// No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
|
|
48
|
+
// and the label on the turn names who it came from.
|
|
54
49
|
chat.wake({
|
|
55
50
|
text: deps.words.prompt(waiting.length),
|
|
56
51
|
label: deps.words.label(waiting),
|
|
@@ -33,11 +33,10 @@ export interface ChatDelivery {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/*
|
|
36
|
-
* Whether the turn a chat is running was itself opened by a message
|
|
37
|
-
*
|
|
38
|
-
* and nothing about that started with a person. It rides on the turn, so it survives a restart.
|
|
36
|
+
* Whether the turn a chat is running was itself opened by a message, which stops two agents that
|
|
37
|
+
* read each other from waking each other forever. It rides on the turn, so it survives a restart.
|
|
39
38
|
* Between turns the last one counts, since work it left running (a background subagent) may still
|
|
40
|
-
* write, and a turn the CLI opened by itself
|
|
39
|
+
* write, and a turn the CLI opened by itself takes the step of the turn before it.
|
|
41
40
|
*/
|
|
42
41
|
export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
|
|
43
42
|
const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
|
|
@@ -76,10 +75,9 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
|
|
|
76
75
|
|
|
77
76
|
/*
|
|
78
77
|
* One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
|
|
79
|
-
* one,
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* host runs something else in, such as a terminal, is the host's to deliver to before this.
|
|
78
|
+
* one, as for a settled task, or the message sits there until someone happens to prompt that chat.
|
|
79
|
+
* One step deep: the turn a message opened wakes nobody with a message of its own. A node that runs
|
|
80
|
+
* something else, such as a terminal, is the host's to deliver to before this.
|
|
83
81
|
*/
|
|
84
82
|
export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
|
|
85
83
|
const waiting = await store.put(notice);
|
|
@@ -105,10 +103,8 @@ export interface NoticeChat {
|
|
|
105
103
|
}
|
|
106
104
|
|
|
107
105
|
/*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
|
|
111
|
-
* has all of it.
|
|
106
|
+
* Shows a person the message in the chat's thread the moment it lands, not when the model gets round
|
|
107
|
+
* to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
|
|
112
108
|
*/
|
|
113
109
|
export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void> {
|
|
114
110
|
if (!(await chat.has(targetId))) {
|
|
@@ -7,10 +7,8 @@ import { RecordDirectory } from '../record-directory.ts';
|
|
|
7
7
|
export const MAX_NOTICES = 10;
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* A message
|
|
11
|
-
*
|
|
12
|
-
* something that has moved on. The cap alone would not do it: one message in a queue of one waits
|
|
13
|
-
* for ever.
|
|
10
|
+
* A message is news about work happening now ("the build is green"), and a model reading it six hours
|
|
11
|
+
* late would act on something that moved on. The cap alone would leave a queue of one waiting forever.
|
|
14
12
|
*/
|
|
15
13
|
export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
16
14
|
|
|
@@ -131,7 +129,7 @@ export class NoticeStore {
|
|
|
131
129
|
return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
/*
|
|
132
|
+
/* In memory and on disk both; an empty queue leaves no file behind. */
|
|
135
133
|
private async persist(targetId: string, queue: Notice[]): Promise<void> {
|
|
136
134
|
if (queue.length === 0) {
|
|
137
135
|
await this.queues.remove(targetId);
|
|
@@ -115,6 +115,9 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
|
|
|
115
115
|
const now = this.clock.now();
|
|
116
116
|
const busy = new Set(this.running);
|
|
117
117
|
let nextDue: number | null = null;
|
|
118
|
+
const dueAt = (at: number): void => {
|
|
119
|
+
nextDue = nextDue === null ? at : Math.min(nextDue, at);
|
|
120
|
+
};
|
|
118
121
|
for (const entry of this.store.list()) {
|
|
119
122
|
// A waiting entry holds no lane. The resume of a chat must not queue behind a wake that waits for it.
|
|
120
123
|
if (busy.has(entry.target) || this.waiting.has(entry.id)) {
|
|
@@ -122,7 +125,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
|
|
|
122
125
|
}
|
|
123
126
|
// Nor does work due at a later time, which nothing owed before it has to wait for.
|
|
124
127
|
if (entry.attempts === 0 && entry.notBefore > now) {
|
|
125
|
-
|
|
128
|
+
dueAt(entry.notBefore);
|
|
126
129
|
continue;
|
|
127
130
|
}
|
|
128
131
|
const lanes = this.store.lanesOf(entry);
|
|
@@ -135,7 +138,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
|
|
|
135
138
|
continue;
|
|
136
139
|
}
|
|
137
140
|
if (entry.notBefore > now) {
|
|
138
|
-
|
|
141
|
+
dueAt(entry.notBefore);
|
|
139
142
|
continue;
|
|
140
143
|
}
|
|
141
144
|
void this.run(entry);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ProviderAccountVariable,
|
|
14
14
|
type ProviderInfo
|
|
15
15
|
} from '@adecore/agent-contracts';
|
|
16
|
+
import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../../chat/codex-transport.ts';
|
|
16
17
|
import { ClientSinks } from '../../client-sinks.ts';
|
|
17
18
|
import { errorText } from '../../error-text.ts';
|
|
18
19
|
import type { AgentEvent, AgentSink } from '../../events.ts';
|
|
@@ -23,7 +24,6 @@ import { prepareShadowHome, type ShadowHomeReport } from './shadow-home.ts';
|
|
|
23
24
|
import { askAccountAs, type AskAccount } from './status.ts';
|
|
24
25
|
import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts, type StoredAccounts } from './store.ts';
|
|
25
26
|
import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError, type AccountsHost, type SecretStore } from './variables.ts';
|
|
26
|
-
import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../../chat/codex-transport.ts';
|
|
27
27
|
|
|
28
28
|
/* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
|
|
29
29
|
const CHECK_INTERVAL_MS = 15 * 60_000;
|
|
@@ -223,21 +223,11 @@ export class ProviderAccountsService implements AccountLaunches {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
transcriptFolder(kind: AgentKind, id: string | undefined): string | null {
|
|
226
|
-
|
|
227
|
-
const account = this.accounts[key];
|
|
228
|
-
if (account === undefined || account.kind !== kind) {
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
return transcriptFolder(key, account, this.providers.get(kind), this.env);
|
|
226
|
+
return this.folderOf(kind, id, transcriptFolder);
|
|
232
227
|
}
|
|
233
228
|
|
|
234
229
|
homeFolder(kind: AgentKind, id: string | undefined): string | null {
|
|
235
|
-
|
|
236
|
-
const account = this.accounts[key];
|
|
237
|
-
if (account === undefined || account.kind !== kind) {
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
return accountFolder(key, account, this.providers.get(kind), this.env);
|
|
230
|
+
return this.folderOf(kind, id, accountFolder);
|
|
241
231
|
}
|
|
242
232
|
|
|
243
233
|
canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean {
|
|
@@ -343,6 +333,15 @@ export class ProviderAccountsService implements AccountLaunches {
|
|
|
343
333
|
return this.snapshot();
|
|
344
334
|
}
|
|
345
335
|
|
|
336
|
+
private folderOf(kind: AgentKind, id: string | undefined, folder: typeof accountFolder): string | null {
|
|
337
|
+
const key = id ?? kind;
|
|
338
|
+
const account = this.accounts[key];
|
|
339
|
+
if (account === undefined || account.kind !== kind) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
return folder(key, account, this.providers.get(kind), this.env);
|
|
343
|
+
}
|
|
344
|
+
|
|
346
345
|
private async runWatch(id: string, watch: LoginWatch): Promise<void> {
|
|
347
346
|
while (this.now() < watch.deadline) {
|
|
348
347
|
await this.sleep(LOGIN_POLL_MS);
|
|
@@ -84,6 +84,7 @@ export function redactVariables(variables: readonly ProviderAccountVariable[]):
|
|
|
84
84
|
export function keychainService(host: AccountsHost, home: string): string {
|
|
85
85
|
return `${host.keychainPrefix}-provider-env-${createHash('sha256').update(resolve(home)).digest('hex').slice(0, 12)}`;
|
|
86
86
|
}
|
|
87
|
+
|
|
87
88
|
// `security` exits with this when no item matches.
|
|
88
89
|
const NOT_FOUND = 44;
|
|
89
90
|
|
package/src/providers/claude.ts
CHANGED
|
@@ -39,7 +39,6 @@ interface ClaudeLaunch {
|
|
|
39
39
|
folders?: readonly string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/* The argument list for one Claude Code chat process. */
|
|
43
42
|
export function claudeArgs(launch: ClaudeLaunch): string[] {
|
|
44
43
|
// The `=` form matters, since the flag is variadic and would swallow a prompt argument after it (measured on Claude Code 2.1.274).
|
|
45
44
|
const args = [
|
package/src/providers/detect.ts
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import { spawnChatProcess, type SpawnChatProcess } from '../chat/chat-process.ts';
|
|
2
|
+
import { definedEnv } from './accounts/launch.ts';
|
|
2
3
|
|
|
3
4
|
export interface CliDetection {
|
|
4
5
|
installed: boolean;
|
|
5
6
|
version: string | null;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
function definedOnly(env: Record<string, string | undefined>): Record<string, string> {
|
|
9
|
-
const defined: Record<string, string> = {};
|
|
10
|
-
for (const [key, value] of Object.entries(env)) {
|
|
11
|
-
if (value !== undefined) {
|
|
12
|
-
defined[key] = value;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
return defined;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
9
|
/* Asks a CLI for its version; a missing binary is a plain "not installed", never an error. */
|
|
19
10
|
export async function detectCli(
|
|
20
11
|
command: string,
|
|
@@ -26,7 +17,7 @@ export async function detectCli(
|
|
|
26
17
|
const code = new Promise<number | null>((resolve) => {
|
|
27
18
|
exited = resolve;
|
|
28
19
|
});
|
|
29
|
-
const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env:
|
|
20
|
+
const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env: definedEnv(env), onExit: (exitCode) => exited(exitCode) });
|
|
30
21
|
proc.stdin.end();
|
|
31
22
|
const output = await new Response(proc.stdout).text();
|
|
32
23
|
if ((await code) !== 0) {
|
package/src/record-directory.ts
CHANGED
|
@@ -121,7 +121,6 @@ export class RecordDirectory<T> {
|
|
|
121
121
|
return rm(this.pathOf(id), { force: true });
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/* Drops every record the predicate says is gone. */
|
|
125
124
|
async prune(drop: (record: T) => boolean): Promise<void> {
|
|
126
125
|
for (const record of this.all()) {
|
|
127
126
|
if (drop(record)) {
|
package/src/skills.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { dirname, join, parse } from 'node:path';
|
|
|
5
5
|
import type { AgentKind, ChatSkill, ChatSkillSource } from '@adecore/agent-contracts';
|
|
6
6
|
import { isNotFound } from './fs.ts';
|
|
7
7
|
|
|
8
|
-
// One folder full of skill folders, and what the CLI calls the skills it finds there.
|
|
9
8
|
export interface SkillRoot {
|
|
10
9
|
dir: string;
|
|
11
10
|
source: ChatSkillSource;
|
|
@@ -116,7 +116,6 @@ export class TaskCoordinator {
|
|
|
116
116
|
await this.deps.tasks.settled();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/* For `chats.observe()`. */
|
|
120
119
|
chatEvent(event: AgentEvent): void {
|
|
121
120
|
if (this.stopped || event.event !== 'chat.event') {
|
|
122
121
|
return;
|
|
@@ -255,37 +254,7 @@ export class TaskCoordinator {
|
|
|
255
254
|
return;
|
|
256
255
|
}
|
|
257
256
|
if (after === 'gone') {
|
|
258
|
-
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
const lost = task.background.itemIds.filter((id) => {
|
|
262
|
-
const item = items.find((item) => item.id === id);
|
|
263
|
-
return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
|
|
264
|
-
});
|
|
265
|
-
if (lost.length > 0 || task.background.commands.length > 0) {
|
|
266
|
-
this.settle(task, 'failed', {
|
|
267
|
-
text: `The agent ended before its background work completed: ${[
|
|
268
|
-
...lost.map((id) => {
|
|
269
|
-
const row = items.find((item) => item.id === id);
|
|
270
|
-
return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
|
|
271
|
-
}),
|
|
272
|
-
...task.background.commands
|
|
273
|
-
].join(', ')}. This work will not send a result.`,
|
|
274
|
-
source: 'exit',
|
|
275
|
-
at: this.deps.now()
|
|
276
|
-
});
|
|
277
|
-
} else {
|
|
278
|
-
const reports = task.background.itemIds.flatMap((id) => {
|
|
279
|
-
const row = items.find((item) => item.id === id);
|
|
280
|
-
const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
|
|
281
|
-
return report ? [report] : [];
|
|
282
|
-
});
|
|
283
|
-
this.settle(task, 'done', {
|
|
284
|
-
text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
|
|
285
|
-
source: 'turn',
|
|
286
|
-
at: this.deps.now()
|
|
287
|
-
});
|
|
288
|
-
}
|
|
257
|
+
this.settleGone(task, task.background, turn, items);
|
|
289
258
|
return;
|
|
290
259
|
}
|
|
291
260
|
} else if (turn.state === 'done' && this.deps.limit.owed(task.id) && this.deps.afterBackgroundWork(chatId) === 'gone') {
|
|
@@ -296,6 +265,39 @@ export class TaskCoordinator {
|
|
|
296
265
|
this.settle(task, status, result);
|
|
297
266
|
}
|
|
298
267
|
|
|
268
|
+
/* The child's process went after its turn was held for background work; what that work left decides the task. */
|
|
269
|
+
private settleGone(task: Task, background: NonNullable<Task['background']>, turn: ChatTurnItem, items: readonly ChatItem[]): void {
|
|
270
|
+
if (background.itemIds.length === 0 && this.deps.limit.owed(task.id)) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const lost = background.itemIds.filter((id) => {
|
|
274
|
+
const item = items.find((item) => item.id === id);
|
|
275
|
+
return !item || (item.kind === 'subagent' ? item.status !== 'done' : item.kind !== 'tool' || item.state !== 'done');
|
|
276
|
+
});
|
|
277
|
+
if (lost.length > 0 || background.commands.length > 0) {
|
|
278
|
+
const names = lost.map((id) => {
|
|
279
|
+
const row = items.find((item) => item.id === id);
|
|
280
|
+
return row?.kind === 'subagent' ? row.description || row.toolUseId : row?.kind === 'tool' ? row.name : id;
|
|
281
|
+
});
|
|
282
|
+
this.settle(task, 'failed', {
|
|
283
|
+
text: `The agent ended before its background work completed: ${[...names, ...background.commands].join(', ')}. This work will not send a result.`,
|
|
284
|
+
source: 'exit',
|
|
285
|
+
at: this.deps.now()
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const reports = background.itemIds.flatMap((id) => {
|
|
290
|
+
const row = items.find((item) => item.id === id);
|
|
291
|
+
const report = row?.kind === 'subagent' ? row.result : row?.kind === 'tool' ? row.output : null;
|
|
292
|
+
return report ? [report] : [];
|
|
293
|
+
});
|
|
294
|
+
this.settle(task, 'done', {
|
|
295
|
+
text: reports.join('\n\n') || resultOfTurn(turn, items, this.deps.now()).result.text,
|
|
296
|
+
source: 'turn',
|
|
297
|
+
at: this.deps.now()
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
299
301
|
/*
|
|
300
302
|
* Whether work in the background keeps this task open past the turn. Commands and monitors alone hold
|
|
301
303
|
* it only until the limit, owed from the moment they are all that is left; a subagent or a workflow
|
|
@@ -354,7 +356,7 @@ export class TaskCoordinator {
|
|
|
354
356
|
if (!task || items === null || this.delegating(chatId)) {
|
|
355
357
|
return;
|
|
356
358
|
}
|
|
357
|
-
const last =
|
|
359
|
+
const last = items.findLast((item): item is ChatTurnItem => item.kind === 'turn');
|
|
358
360
|
if (last && last.state !== 'running' && last.createdAt >= task.createdAt) {
|
|
359
361
|
this.turnEnded(chatId, last);
|
|
360
362
|
}
|
package/src/tasks/task-store.ts
CHANGED
|
@@ -108,7 +108,7 @@ export class TaskStore {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/*
|
|
111
|
-
* Ends an open task with its result;
|
|
111
|
+
* Ends an open task with its result; null when it was not open any more, which is how the first of
|
|
112
112
|
* `done`, the end of a turn and an exit wins. A cancelled task wakes nobody.
|
|
113
113
|
*/
|
|
114
114
|
async settle(id: string, status: 'done' | 'failed' | 'cancelled', result: TaskResult | null, now: number): Promise<Task | null> {
|
package/src/tasks/task-work.ts
CHANGED
|
@@ -3,8 +3,8 @@ import type { OutboxEntryOf, OutboxWorkShape } from '../outbox/outbox.ts';
|
|
|
3
3
|
|
|
4
4
|
export const BackgroundLimitWorkSchema = z.object({
|
|
5
5
|
kind: z.literal('background-limit'),
|
|
6
|
-
// The target is the child
|
|
7
|
-
// `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
|
|
6
|
+
// The target is the child. `commands` are what held its task open when the limit started, for a child whose
|
|
7
|
+
// process went since; `restarted` marks an entry an earlier run of the host owed, whose commands went down with it.
|
|
8
8
|
payload: z.object({ taskId: z.string().min(1), commands: z.array(z.string()), restarted: z.literal(true).optional() })
|
|
9
9
|
});
|
|
10
10
|
|
|
@@ -82,7 +82,6 @@ export class WaitingObserver {
|
|
|
82
82
|
this.stopped = true;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/* For `chats.observe()`. */
|
|
86
85
|
chatEvent(event: AgentEvent): void {
|
|
87
86
|
if (this.stopped || event.event !== 'chat.event') {
|
|
88
87
|
return;
|
|
@@ -108,15 +107,19 @@ export class WaitingObserver {
|
|
|
108
107
|
}
|
|
109
108
|
}
|
|
110
109
|
|
|
110
|
+
function oneLine(text: string): string {
|
|
111
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
111
114
|
function quoted(text: string): string {
|
|
112
|
-
return JSON.stringify(text
|
|
115
|
+
return JSON.stringify(oneLine(text));
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
/* The one line a note shows before "Show more": a question longer than that is read in the node. */
|
|
116
119
|
const NOTE_QUESTION_LENGTH = 200;
|
|
117
120
|
|
|
118
121
|
function quotedShort(text: string): string {
|
|
119
|
-
const line = text
|
|
122
|
+
const line = oneLine(text);
|
|
120
123
|
return JSON.stringify(line.length > NOTE_QUESTION_LENGTH ? `${clipText(line, NOTE_QUESTION_LENGTH - 1)}…` : line);
|
|
121
124
|
}
|
|
122
125
|
|
package/src/tasks/wake-parent.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Task } from '@adecore/agent-contracts';
|
|
1
|
+
import type { ChatItem, Task } from '@adecore/agent-contracts';
|
|
2
2
|
import type { WakeChat } from '../chat/wake-chat.ts';
|
|
3
3
|
import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
|
|
4
4
|
import type { TaskStore } from './task-store.ts';
|
|
@@ -56,6 +56,24 @@ export function wakeNote(tasks: readonly Task[]): string {
|
|
|
56
56
|
return `Woken by ${tasks.length} finished ${tasks.length === 1 ? 'task' : 'tasks'}`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/* An unacknowledged attempt waits for a person's next turn, rather than retrying on every chat event. */
|
|
60
|
+
function deliveryBlocked(items: readonly ChatItem[], tasks: readonly Task[]): boolean {
|
|
61
|
+
return items.some(
|
|
62
|
+
(item, index) =>
|
|
63
|
+
item.kind === 'turn' &&
|
|
64
|
+
item.deliveryPending &&
|
|
65
|
+
item.taskIds?.some((id) => tasks.some((task) => task.id === id)) &&
|
|
66
|
+
(item.state === 'running' || !items.slice(index + 1).some((later) => later.kind === 'turn' && later.origin !== 'agent'))
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function deliveryPending(items: readonly ChatItem[], tasks: readonly Task[]): boolean {
|
|
71
|
+
return tasks.some((task) => {
|
|
72
|
+
const turn = items.findLast((item) => item.kind === 'turn' && item.taskIds?.includes(task.id));
|
|
73
|
+
return turn?.kind === 'turn' && turn.deliveryPending;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
export interface WakeParentDeps {
|
|
60
78
|
tasks: Pick<TaskStore, 'pendingWake' | 'readyWake' | 'openBatches' | 'markWoken' | 'dropWake'>;
|
|
61
79
|
/* The chat as it is now, loading it from disk when nobody has; null for a chat that has no thread any more. */
|
|
@@ -96,15 +114,7 @@ export function wakeParentHandler(deps: WakeParentDeps) {
|
|
|
96
114
|
if (tasks.length === 0) {
|
|
97
115
|
return held();
|
|
98
116
|
}
|
|
99
|
-
|
|
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) {
|
|
117
|
+
if (deliveryBlocked(items, tasks)) {
|
|
108
118
|
return 'wait';
|
|
109
119
|
}
|
|
110
120
|
const teamsOut = deps.tasks.openBatches(parentId).length;
|
|
@@ -119,12 +129,7 @@ export function wakeParentHandler(deps: WakeParentDeps) {
|
|
|
119
129
|
return 'wait';
|
|
120
130
|
}
|
|
121
131
|
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
|
-
) {
|
|
132
|
+
if (deliveryPending(chat.items(), tasks)) {
|
|
128
133
|
return 'wait';
|
|
129
134
|
}
|
|
130
135
|
await deps.tasks.markWoken(tasks.map((task) => task.id));
|
package/src/tasks/wiring.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { reportsOnBackgroundWork } from '../chat/background-work.ts';
|
|
|
3
3
|
import type { ChatCore } from '../chat/chat-core.ts';
|
|
4
4
|
import { chatOpener, loadChat } from '../chat/wake-chat.ts';
|
|
5
5
|
import { errorText } from '../error-text.ts';
|
|
6
|
+
import { chatNoticeTargets } from '../messages/deliver-notice.ts';
|
|
6
7
|
import type { OutboxHandlers } from '../outbox/outbox-worker.ts';
|
|
7
8
|
import { backgroundLimitHandler, backgroundLimits } from './background-limit.ts';
|
|
8
9
|
import { giveTaskHandler } from './give-task.ts';
|
|
@@ -85,6 +86,7 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
|
|
|
85
86
|
const enqueue = (projectId: string, target: string, work: TaskWork, notBefore?: number): Promise<void> =>
|
|
86
87
|
outbox.enqueue(projectId, target, work, notBefore);
|
|
87
88
|
const limit = backgroundLimits(outbox);
|
|
89
|
+
const owesGiveTask = (taskId: string): boolean => outbox.list().some((entry) => isGiveTask(entry) && entry.payload.taskId === taskId);
|
|
88
90
|
const coordinator = new TaskCoordinator({
|
|
89
91
|
tasks: deps.tasks,
|
|
90
92
|
now,
|
|
@@ -92,7 +94,7 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
|
|
|
92
94
|
placed: (nodeId) => deps.placed(nodeId),
|
|
93
95
|
loading: (chatId) => chats.isLoading(chatId),
|
|
94
96
|
// Until the turn that carries a task ran, no turn of the child is its answer.
|
|
95
|
-
owedTurn:
|
|
97
|
+
owedTurn: owesGiveTask,
|
|
96
98
|
oweWake: (task) => oweWake({ enqueue }, task),
|
|
97
99
|
alert: (nodeId, title, body) => deps.alert(nodeId, title, body),
|
|
98
100
|
...(deps.retryAt ? { retryAt: deps.retryAt } : {}),
|
|
@@ -131,43 +133,40 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
|
|
|
131
133
|
});
|
|
132
134
|
|
|
133
135
|
const chatFor = chatOpener({ chats, placed: deps.placed });
|
|
136
|
+
const targets = chatNoticeTargets(chats);
|
|
137
|
+
|
|
138
|
+
/* Owes again what an earlier run left of an open task, and lets its child's last turn settle it. */
|
|
139
|
+
const recoverOpen = async (task: Task): Promise<void> => {
|
|
140
|
+
if (task.requiresTaskTurn === true && deps.placed(task.childId) && !owesGiveTask(task.id)) {
|
|
141
|
+
await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
|
|
142
|
+
}
|
|
143
|
+
const stored = await chats.storedBackground(task.childId);
|
|
144
|
+
const storedWork = stored.items.length > 0 || stored.commands.length > 0;
|
|
145
|
+
if (!deps.placed(task.childId) || (task.background === undefined && task.requiresTaskTurn !== true && !storedWork)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (task.background === undefined && stored.turnId !== null && storedWork) {
|
|
149
|
+
await deps.tasks.holdBackground(task.id, {
|
|
150
|
+
turnId: stored.turnId,
|
|
151
|
+
itemIds: stored.items.map((item) => item.id),
|
|
152
|
+
commands: stored.commands
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const chat = await loadChat({ chats, placed: deps.placed }, task.childId);
|
|
156
|
+
if (chat && chat.info.activeTurnId === null) {
|
|
157
|
+
const last = chat.thread.list().findLast((item) => item.kind === 'turn');
|
|
158
|
+
if (last?.kind === 'turn') {
|
|
159
|
+
coordinator.chatEvent({ event: 'chat.event', payload: { chatId: task.childId, event: { type: 'item', item: last } } });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
134
163
|
|
|
135
164
|
return {
|
|
136
165
|
coordinator,
|
|
137
166
|
recover: async () => {
|
|
138
167
|
for (const task of deps.tasks.all()) {
|
|
139
|
-
if (task.status === 'open'
|
|
140
|
-
|
|
141
|
-
await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
const storedBackground = task.status === 'open' ? await chats.storedBackground(task.childId) : { turnId: null, items: [], commands: [] };
|
|
145
|
-
if (
|
|
146
|
-
task.status === 'open' &&
|
|
147
|
-
deps.placed(task.childId) &&
|
|
148
|
-
(task.background !== undefined ||
|
|
149
|
-
task.requiresTaskTurn === true ||
|
|
150
|
-
storedBackground.items.length > 0 ||
|
|
151
|
-
storedBackground.commands.length > 0)
|
|
152
|
-
) {
|
|
153
|
-
if (
|
|
154
|
-
task.background === undefined &&
|
|
155
|
-
storedBackground.turnId !== null &&
|
|
156
|
-
(storedBackground.items.length > 0 || storedBackground.commands.length > 0)
|
|
157
|
-
) {
|
|
158
|
-
await deps.tasks.holdBackground(task.id, {
|
|
159
|
-
turnId: storedBackground.turnId,
|
|
160
|
-
itemIds: storedBackground.items.map((item) => item.id),
|
|
161
|
-
commands: storedBackground.commands
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
const chat = await loadChat({ chats, placed: deps.placed }, task.childId);
|
|
165
|
-
if (chat && chat.info.activeTurnId === null) {
|
|
166
|
-
const last = chat.thread.list().findLast((item) => item.kind === 'turn');
|
|
167
|
-
if (last?.kind === 'turn') {
|
|
168
|
-
coordinator.chatEvent({ event: 'chat.event', payload: { chatId: task.childId, event: { type: 'item', item: last } } });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
168
|
+
if (task.status === 'open') {
|
|
169
|
+
await recoverOpen(task);
|
|
171
170
|
}
|
|
172
171
|
}
|
|
173
172
|
await coordinator.settled();
|
|
@@ -190,14 +189,7 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
|
|
|
190
189
|
await enqueue(task.projectId, task.childId, { kind: 'give-task', payload: { taskId: task.id } });
|
|
191
190
|
return task;
|
|
192
191
|
},
|
|
193
|
-
chatState:
|
|
194
|
-
const session = chats.get(nodeId);
|
|
195
|
-
if (session) {
|
|
196
|
-
return session.info.activeTurnId === null ? 'idle' : 'running';
|
|
197
|
-
}
|
|
198
|
-
// A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
|
|
199
|
-
return (await chats.hasStored(nodeId)) ? 'idle' : 'none';
|
|
200
|
-
},
|
|
192
|
+
chatState: (nodeId) => targets.chat(nodeId),
|
|
201
193
|
done: (childId, text) => coordinator.done(childId, text),
|
|
202
194
|
involving: (nodeId) => deps.tasks.involving(nodeId)
|
|
203
195
|
},
|