@hivemagnus/ardent-studio 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +12 -0
- package/README.md +133 -2
- package/dist/agents/config-edit.d.ts +20 -0
- package/dist/agents/config-edit.js +75 -0
- package/dist/agents/directory.d.ts +27 -0
- package/dist/agents/directory.js +97 -0
- package/dist/agents/operations.d.ts +47 -0
- package/dist/agents/operations.js +93 -0
- package/dist/auth/links.d.ts +27 -0
- package/dist/auth/links.js +46 -0
- package/dist/auth/oathweave.d.ts +46 -0
- package/dist/auth/oathweave.js +123 -0
- package/dist/blobs/store.d.ts +45 -0
- package/dist/blobs/store.js +159 -0
- package/dist/clients/chattermost.d.ts +16 -0
- package/dist/clients/chattermost.js +36 -0
- package/dist/clients/hivemem.d.ts +85 -0
- package/dist/clients/hivemem.js +89 -0
- package/dist/clients/magnus.d.ts +851 -0
- package/dist/clients/magnus.js +541 -0
- package/dist/clients/service-token.d.ts +24 -0
- package/dist/clients/service-token.js +87 -0
- package/dist/clients/sse.d.ts +8 -0
- package/dist/clients/sse.js +42 -0
- package/dist/conductor/activity.d.ts +38 -0
- package/dist/conductor/activity.js +101 -0
- package/dist/conductor/addressing.d.ts +24 -0
- package/dist/conductor/addressing.js +42 -0
- package/dist/conductor/conductor.d.ts +225 -0
- package/dist/conductor/conductor.js +939 -0
- package/dist/conductor/follower.d.ts +48 -0
- package/dist/conductor/follower.js +154 -0
- package/dist/conductor/live.d.ts +23 -0
- package/dist/conductor/live.js +32 -0
- package/dist/conductor/prompting.d.ts +74 -0
- package/dist/conductor/prompting.js +177 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.js +163 -0
- package/dist/db/database.d.ts +12 -0
- package/dist/db/database.js +62 -0
- package/dist/db/json.d.ts +8 -0
- package/dist/db/json.js +13 -0
- package/dist/db/migrations.d.ts +6 -0
- package/dist/db/migrations.js +571 -0
- package/dist/db/repos/agents.d.ts +86 -0
- package/dist/db/repos/agents.js +92 -0
- package/dist/db/repos/audit.d.ts +37 -0
- package/dist/db/repos/audit.js +20 -0
- package/dist/db/repos/events.d.ts +29 -0
- package/dist/db/repos/events.js +55 -0
- package/dist/db/repos/files.d.ts +41 -0
- package/dist/db/repos/files.js +72 -0
- package/dist/db/repos/idempotency.d.ts +27 -0
- package/dist/db/repos/idempotency.js +46 -0
- package/dist/db/repos/links.d.ts +47 -0
- package/dist/db/repos/links.js +99 -0
- package/dist/db/repos/projects.d.ts +44 -0
- package/dist/db/repos/projects.js +107 -0
- package/dist/db/repos/proposals.d.ts +46 -0
- package/dist/db/repos/proposals.js +65 -0
- package/dist/db/repos/review.d.ts +191 -0
- package/dist/db/repos/review.js +195 -0
- package/dist/db/repos/room.d.ts +163 -0
- package/dist/db/repos/room.js +151 -0
- package/dist/db/repos/sessions.d.ts +36 -0
- package/dist/db/repos/sessions.js +30 -0
- package/dist/db/repos/summaries.d.ts +22 -0
- package/dist/db/repos/summaries.js +105 -0
- package/dist/db/repos/tasks.d.ts +103 -0
- package/dist/db/repos/tasks.js +112 -0
- package/dist/db/repos/team.d.ts +49 -0
- package/dist/db/repos/team.js +95 -0
- package/dist/db/repos/templates.d.ts +55 -0
- package/dist/db/repos/templates.js +120 -0
- package/dist/db/store.d.ts +57 -0
- package/dist/db/store.js +108 -0
- package/dist/events/bus.d.ts +21 -0
- package/dist/events/bus.js +31 -0
- package/dist/events/types.d.ts +3 -0
- package/dist/events/types.js +1 -0
- package/dist/ids.d.ts +28 -0
- package/dist/ids.js +81 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/knowledge/extract.d.ts +17 -0
- package/dist/knowledge/extract.js +82 -0
- package/dist/knowledge/sync.d.ts +40 -0
- package/dist/knowledge/sync.js +172 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +11 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +42 -0
- package/dist/review/anchors.d.ts +16 -0
- package/dist/review/anchors.js +67 -0
- package/dist/review/context.d.ts +4 -0
- package/dist/review/context.js +29 -0
- package/dist/review/docx.d.ts +29 -0
- package/dist/review/docx.js +21 -0
- package/dist/review/export.d.ts +16 -0
- package/dist/review/export.js +84 -0
- package/dist/review/importer.d.ts +68 -0
- package/dist/review/importer.js +215 -0
- package/dist/review/index.d.ts +13 -0
- package/dist/review/index.js +7 -0
- package/dist/server/app.d.ts +5 -0
- package/dist/server/app.js +44 -0
- package/dist/server/deps.d.ts +43 -0
- package/dist/server/deps.js +17 -0
- package/dist/server/env.d.ts +6 -0
- package/dist/server/env.js +1 -0
- package/dist/server/errors.d.ts +12 -0
- package/dist/server/errors.js +25 -0
- package/dist/server/routes/agent-studio.d.ts +5 -0
- package/dist/server/routes/agent-studio.js +441 -0
- package/dist/server/routes/agents.d.ts +3 -0
- package/dist/server/routes/agents.js +4 -0
- package/dist/server/routes/files.d.ts +3 -0
- package/dist/server/routes/files.js +88 -0
- package/dist/server/routes/health.d.ts +3 -0
- package/dist/server/routes/health.js +31 -0
- package/dist/server/routes/links.d.ts +3 -0
- package/dist/server/routes/links.js +79 -0
- package/dist/server/routes/projects.d.ts +3 -0
- package/dist/server/routes/projects.js +119 -0
- package/dist/server/routes/review.d.ts +3 -0
- package/dist/server/routes/review.js +198 -0
- package/dist/server/routes/room.d.ts +9 -0
- package/dist/server/routes/room.js +224 -0
- package/dist/server/routes/stream.d.ts +4 -0
- package/dist/server/routes/stream.js +104 -0
- package/dist/server/routes/team.d.ts +3 -0
- package/dist/server/routes/team.js +183 -0
- package/dist/server/routes/templates.d.ts +11 -0
- package/dist/server/routes/templates.js +103 -0
- package/dist/server/routes/work.d.ts +3 -0
- package/dist/server/routes/work.js +139 -0
- package/dist/server/routing.d.ts +31 -0
- package/dist/server/routing.js +132 -0
- package/dist/service.d.ts +34 -0
- package/dist/service.js +93 -0
- package/dist/shared/browser.d.ts +5824 -0
- package/dist/shared/browser.js +1318 -0
- package/dist/team/proposals.d.ts +129 -0
- package/dist/team/proposals.js +349 -0
- package/dist/team/service.d.ts +12 -0
- package/dist/team/service.js +13 -0
- package/dist/team/validation.d.ts +37 -0
- package/dist/team/validation.js +157 -0
- package/dist/templates/secrets.d.ts +2 -0
- package/dist/templates/secrets.js +23 -0
- package/dist/templates/seeds.d.ts +34 -0
- package/dist/templates/seeds.js +277 -0
- package/dist/url.d.ts +2 -0
- package/dist/url.js +7 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/docs/openapi-browser.json +12658 -0
- package/package.json +91 -4
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
// The conductor: one worker inside ArdentStudio that starts agent turns through Magnus, follows
|
|
2
|
+
// them, relays live reply text, writes the final messages and enforces team rules on what
|
|
3
|
+
// agents request. It never runs model work, and everything it records comes from a person's
|
|
4
|
+
// request or a real Ardent record. See docs/CONDUCTOR.md.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { isAvailable } from '../agents/directory.js';
|
|
8
|
+
import { ARDENT_STUDIO_CAPABILITY, MagnusError, parseAgentRef } from '../clients/magnus.js';
|
|
9
|
+
import { ServiceTokenError } from '../clients/service-token.js';
|
|
10
|
+
import { hivememProjectId } from '../db/repos/projects.js';
|
|
11
|
+
import { sessionKey } from '../db/repos/sessions.js';
|
|
12
|
+
import { TERMINAL_TASK_STATES } from '../db/repos/tasks.js';
|
|
13
|
+
import { deriveActivity, excerpt } from './activity.js';
|
|
14
|
+
import { ARDENT_FINISHED, TaskFollower } from './follower.js';
|
|
15
|
+
import { buildMission, composeTurn, HANDOFF_EDGES } from './prompting.js';
|
|
16
|
+
export class ConductorError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
code;
|
|
19
|
+
name = 'ConductorError';
|
|
20
|
+
constructor(message, status, code) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const ROOM_HISTORY = 50;
|
|
27
|
+
const ATTACHMENT_TEXT_LIMIT = 256 * 1024;
|
|
28
|
+
const TEXT_MIME = /^(text\/|application\/json$|image\/svg\+xml$)/;
|
|
29
|
+
const FINISHED = [...TERMINAL_TASK_STATES, 'input_required'];
|
|
30
|
+
const handoffSchema = z.object({ to_role: z.string(), objective: z.string(), deliverables: z.array(z.string()).optional(), note: z.string().optional() });
|
|
31
|
+
const askSchema = z.object({ question: z.string(), options: z.array(z.string()).optional() });
|
|
32
|
+
const proposalSchema = z.object({ title: z.string(), statement: z.string(), rationale: z.string().optional() });
|
|
33
|
+
const stateSchema = z.object({ to: z.string() });
|
|
34
|
+
const toolSchema = z.object({ name: z.string() });
|
|
35
|
+
const teamResultSchema = z.object({ outcome: z.string(), summary: z.string(), artifacts: z.array(z.object({ name: z.string(), sha256: z.string() }).loose()).optional() }).loose();
|
|
36
|
+
/** A stable UUID for a Studio task, so a retried submission is the same Ardent task. */
|
|
37
|
+
export function ardentIdempotencyKey(taskId) {
|
|
38
|
+
const hex = createHash('sha256').update(`ardentstudio:${taskId}`).digest('hex');
|
|
39
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
40
|
+
}
|
|
41
|
+
function transient(error) {
|
|
42
|
+
if (error instanceof ServiceTokenError)
|
|
43
|
+
return true;
|
|
44
|
+
if (error instanceof MagnusError)
|
|
45
|
+
return error.status >= 500 || error.status === 429 || error.status === 408;
|
|
46
|
+
return !(error instanceof z.ZodError);
|
|
47
|
+
}
|
|
48
|
+
function errorText(error) {
|
|
49
|
+
return error instanceof Error ? error.message.slice(0, 300) : 'unknown error';
|
|
50
|
+
}
|
|
51
|
+
const versionAtLeast = (version, minimum) => {
|
|
52
|
+
const match = version ? /^(\d+)\.(\d+)\.(\d+)/.exec(version) : null;
|
|
53
|
+
if (!match)
|
|
54
|
+
return false;
|
|
55
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
56
|
+
for (let index = 0; index < 3; index++)
|
|
57
|
+
if (parts[index] !== minimum[index])
|
|
58
|
+
return parts[index] > minimum[index];
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
export class Conductor {
|
|
62
|
+
options;
|
|
63
|
+
followers = new Map();
|
|
64
|
+
submitting = new Set();
|
|
65
|
+
retryAt = new Map();
|
|
66
|
+
capabilities = new Map();
|
|
67
|
+
/** Last presence probe per agent. Absent until probed. */
|
|
68
|
+
presence = new Map();
|
|
69
|
+
/** Re-derive an agent's activity when its Delivering window ends, per project and binding. */
|
|
70
|
+
deliveringTimers = new Map();
|
|
71
|
+
hooks = [];
|
|
72
|
+
contextProviders = [];
|
|
73
|
+
modes = new Map();
|
|
74
|
+
pumping;
|
|
75
|
+
pumpAgain = false;
|
|
76
|
+
retryTimer;
|
|
77
|
+
presenceTimer;
|
|
78
|
+
stopped = false;
|
|
79
|
+
store;
|
|
80
|
+
magnus;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.options = options;
|
|
83
|
+
this.store = options.store;
|
|
84
|
+
this.magnus = options.magnus;
|
|
85
|
+
}
|
|
86
|
+
/** Register a journal hook (for example the artifact importer). */
|
|
87
|
+
onJournalEvent(hook) {
|
|
88
|
+
this.hooks.push(hook);
|
|
89
|
+
}
|
|
90
|
+
/** Register extra turn context (for example the review comments of a revision task). */
|
|
91
|
+
addContextProvider(provider) {
|
|
92
|
+
this.contextProviders.push(provider);
|
|
93
|
+
}
|
|
94
|
+
/** Handle tasks of this mode with the handler instead of the room (for example team proposals). */
|
|
95
|
+
registerMode(mode, handler) {
|
|
96
|
+
this.modes.set(mode, handler);
|
|
97
|
+
}
|
|
98
|
+
/** Resume following every accepted task from its stored cursor, finish discussions a restart interrupted, and start dispatching. */
|
|
99
|
+
start() {
|
|
100
|
+
this.stopped = false;
|
|
101
|
+
for (const task of this.store.tasks.following())
|
|
102
|
+
this.follow(task);
|
|
103
|
+
for (const conversation of this.runningDiscussions())
|
|
104
|
+
this.advanceDiscussion(conversation.id);
|
|
105
|
+
this.kick();
|
|
106
|
+
void this.probe();
|
|
107
|
+
this.presenceTimer = setInterval(() => { void this.probe(); }, this.options.presenceMs ?? 30_000);
|
|
108
|
+
this.presenceTimer.unref();
|
|
109
|
+
}
|
|
110
|
+
async stop() {
|
|
111
|
+
this.stopped = true;
|
|
112
|
+
if (this.presenceTimer)
|
|
113
|
+
clearInterval(this.presenceTimer);
|
|
114
|
+
if (this.retryTimer)
|
|
115
|
+
clearTimeout(this.retryTimer);
|
|
116
|
+
for (const timer of this.deliveringTimers.values())
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
this.deliveringTimers.clear();
|
|
119
|
+
await Promise.all([...this.followers.values()].map(follower => follower.stop()));
|
|
120
|
+
this.followers.clear();
|
|
121
|
+
await this.pumping;
|
|
122
|
+
}
|
|
123
|
+
/** Tasks the conductor currently follows (for tests and diagnostics). */
|
|
124
|
+
followerOf(taskId) {
|
|
125
|
+
return this.followers.get(taskId);
|
|
126
|
+
}
|
|
127
|
+
// ── Dispatch ──
|
|
128
|
+
/** Submit every task that is ready. Safe to call often: runs are serialized. */
|
|
129
|
+
kick() {
|
|
130
|
+
if (this.stopped)
|
|
131
|
+
return;
|
|
132
|
+
if (this.pumping) {
|
|
133
|
+
this.pumpAgain = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.pumping = this.pump().finally(() => {
|
|
137
|
+
this.pumping = undefined;
|
|
138
|
+
if (this.pumpAgain) {
|
|
139
|
+
this.pumpAgain = false;
|
|
140
|
+
this.kick();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Resolves when no dispatch run is in progress. */
|
|
145
|
+
async settled() {
|
|
146
|
+
while (this.pumping)
|
|
147
|
+
await this.pumping;
|
|
148
|
+
}
|
|
149
|
+
async pump() {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const ready = [];
|
|
152
|
+
for (const task of this.store.tasks.unsubmitted()) {
|
|
153
|
+
if (this.submitting.has(task.id) || (this.retryAt.get(task.id) ?? 0) > now)
|
|
154
|
+
continue;
|
|
155
|
+
const project = this.store.projects.get(task.project_id);
|
|
156
|
+
if (!project || project.status !== 'active')
|
|
157
|
+
continue;
|
|
158
|
+
const conversation = task.conversation_id ? this.store.room.conversation(task.conversation_id) : undefined;
|
|
159
|
+
if (conversation?.stopped_at) {
|
|
160
|
+
this.cancelLocally(task, 'The conversation was stopped before this turn started.');
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const ahead = this.store.tasks.groupBefore(task).filter(item => !FINISHED.includes(item.state));
|
|
164
|
+
if (ahead.length) {
|
|
165
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
ready.push(task);
|
|
169
|
+
}
|
|
170
|
+
await Promise.all(ready.map(task => this.submit(task)));
|
|
171
|
+
}
|
|
172
|
+
resolve(task) {
|
|
173
|
+
const project = this.store.projects.get(task.project_id);
|
|
174
|
+
if (!project)
|
|
175
|
+
return 'The project no longer exists.';
|
|
176
|
+
const revision = task.assignment_revision ? this.store.team.revision(project.id, task.assignment_revision) : this.store.team.current(project.id);
|
|
177
|
+
if (!revision)
|
|
178
|
+
return 'The project has no published team.';
|
|
179
|
+
const role = revision.body.roles.find(item => item.key === task.role_key);
|
|
180
|
+
if (!role)
|
|
181
|
+
return `Team r${revision.revision} has no role "${task.role_key ?? ''}".`;
|
|
182
|
+
if (!role.agent_ref)
|
|
183
|
+
return `${role.name} has no bound agent in team r${revision.revision}.`;
|
|
184
|
+
const bindingId = role.binding_id ?? this.store.team.ensureBinding(role.agent_ref).id;
|
|
185
|
+
return { project, revision, role, agentRef: role.agent_ref, bindingId };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Studio level and whether the agent runs an Ardent with missions, both from the node's one
|
|
189
|
+
* installation record (every profile on a node runs its one Ardent package). Cached briefly.
|
|
190
|
+
* A failed or stale agent list never decides this, so a mission is not silently sent as a
|
|
191
|
+
* plain task without its tools.
|
|
192
|
+
*/
|
|
193
|
+
async capability(agentRef) {
|
|
194
|
+
const { nodeId } = parseAgentRef(agentRef);
|
|
195
|
+
let cached = this.capabilities.get(nodeId);
|
|
196
|
+
if (!cached || Date.now() - cached.at >= (this.options.capabilityCacheMs ?? 60_000)) {
|
|
197
|
+
const installation = await this.magnus.getInstallation(nodeId);
|
|
198
|
+
cached = { level: installation.capabilities?.studio ?? 0, version: installation.version, at: Date.now() };
|
|
199
|
+
this.capabilities.set(nodeId, cached);
|
|
200
|
+
}
|
|
201
|
+
const { level, version } = cached;
|
|
202
|
+
return { level, missions: level >= ARDENT_STUDIO_CAPABILITY.missions && versionAtLeast(version, [2, 2, 0]) };
|
|
203
|
+
}
|
|
204
|
+
/** A room turn: the role's agent under the task's team revision, with an Ardent session when the node supports one. */
|
|
205
|
+
async planTurn(task) {
|
|
206
|
+
const resolved = this.resolve(task);
|
|
207
|
+
if (typeof resolved === 'string')
|
|
208
|
+
return resolved;
|
|
209
|
+
const { project, revision, role, agentRef } = resolved;
|
|
210
|
+
const { level, missions } = await this.capability(agentRef);
|
|
211
|
+
const conversationId = task.conversation_id ?? this.ensureTeamConversation(project.id, 'conductor').id;
|
|
212
|
+
const context = missions ? 'mission' : 'direct';
|
|
213
|
+
let session = level >= ARDENT_STUDIO_CAPABILITY.bridge ? this.store.sessions.get(conversationId, role.key, agentRef, context) : undefined;
|
|
214
|
+
if (level >= ARDENT_STUDIO_CAPABILITY.bridge && !session) {
|
|
215
|
+
const key = sessionKey(project.id, conversationId, role.key);
|
|
216
|
+
const opened = await this.magnus.openSession(agentRef, missions ? { key, context } : { key });
|
|
217
|
+
session = this.store.sessions.save({ conversationId, roleKey: role.key, agentRef, context, projectId: project.id, sessionKey: key, sessionId: opened.id });
|
|
218
|
+
}
|
|
219
|
+
const turn = this.compose(task, resolved, conversationId, session?.last_seen_seq ?? 0, !session || session.turns === 0);
|
|
220
|
+
return {
|
|
221
|
+
agentRef, dispatch: missions ? 'mission' : 'turn', session, seenSeq: turn.seenSeq,
|
|
222
|
+
objective: turn.composed.objective, contextItems: turn.composed.contextItems, prompt: turn.composed.prompt,
|
|
223
|
+
mission: missions ? buildMission({ task, project, team: revision.body, role, hivememProjectId: hivememProjectId(project.id), now: new Date() }) : undefined,
|
|
224
|
+
labels: { 'studio.project': project.id, 'studio.task': task.id, 'studio.conversation': conversationId, 'studio.role': role.key },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** A handled mode: one request without a session, composed by its handler. */
|
|
228
|
+
async planHandled(task, handler) {
|
|
229
|
+
const project = this.store.projects.get(task.project_id);
|
|
230
|
+
if (!project)
|
|
231
|
+
return 'The project no longer exists.';
|
|
232
|
+
const chosen = handler.agent(task);
|
|
233
|
+
if ('error' in chosen)
|
|
234
|
+
return chosen.error;
|
|
235
|
+
const { missions } = await this.capability(chosen.agentRef);
|
|
236
|
+
const dispatch = missions ? 'mission' : 'turn';
|
|
237
|
+
const request = handler.compose(task, { project, dispatch, now: new Date() });
|
|
238
|
+
return {
|
|
239
|
+
agentRef: chosen.agentRef, dispatch, session: undefined, seenSeq: 0, objective: request.objective, contextItems: request.contextItems,
|
|
240
|
+
prompt: request.prompt, mission: missions ? request.mission : undefined,
|
|
241
|
+
labels: { 'studio.project': project.id, 'studio.task': task.id, 'studio.mode': task.mode },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async submit(task) {
|
|
245
|
+
this.submitting.add(task.id);
|
|
246
|
+
try {
|
|
247
|
+
const handler = this.modes.get(task.mode);
|
|
248
|
+
const plan = handler ? await this.planHandled(task, handler) : await this.planTurn(task);
|
|
249
|
+
if (typeof plan === 'string') {
|
|
250
|
+
this.failTask(task, plan);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const { agentRef, dispatch, session, mission } = plan;
|
|
254
|
+
let admission;
|
|
255
|
+
if (mission) {
|
|
256
|
+
admission = await this.magnus.submitMission(agentRef, {
|
|
257
|
+
...(session ? { session_id: session.session_id } : {}), objective: plan.objective, mission,
|
|
258
|
+
...(plan.contextItems.length ? { context_items: plan.contextItems } : {}),
|
|
259
|
+
labels: plan.labels,
|
|
260
|
+
idempotency_key: task.id,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
admission = await this.magnus.submitTask(agentRef, { prompt: plan.prompt, idempotency_key: ardentIdempotencyKey(task.id), ...(session ? { session_id: session.session_id } : {}) });
|
|
265
|
+
}
|
|
266
|
+
this.retryAt.delete(task.id);
|
|
267
|
+
let accepted;
|
|
268
|
+
this.store.tx(() => {
|
|
269
|
+
const current = this.store.tasks.get(task.id);
|
|
270
|
+
accepted = this.store.tasks.attachArdent(task.id, { agentRef, taskId: admission.task_id, sessionId: session?.session_id ?? null, dispatch, ...(mission ? { mission } : {}) });
|
|
271
|
+
if (current.state === 'queued' && admission.state === 'running')
|
|
272
|
+
accepted = this.store.tasks.setState(task.id, 'running');
|
|
273
|
+
if (session)
|
|
274
|
+
this.store.sessions.recordTurn(session, plan.seenSeq);
|
|
275
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(accepted));
|
|
276
|
+
this.refreshActivity(task.project_id, accepted.binding_id);
|
|
277
|
+
});
|
|
278
|
+
// A pause or cancel asked for while the submission was in flight applies now.
|
|
279
|
+
if (accepted.state === 'paused')
|
|
280
|
+
void this.magnus.pauseTask(agentRef, admission.task_id).catch(error => this.options.logger.warn('Pause after admission failed', { task_id: task.id, error: errorText(error) }));
|
|
281
|
+
if (accepted.state === 'cancelled') {
|
|
282
|
+
void this.magnus.cancelTask(agentRef, admission.task_id).catch(error => this.options.logger.warn('Cancel after admission failed', { task_id: task.id, error: errorText(error) }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.follow(accepted);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (transient(error)) {
|
|
289
|
+
const attempts = this.store.tasks.countAttempt(task.id);
|
|
290
|
+
if (attempts < (this.options.maxSubmitAttempts ?? 5)) {
|
|
291
|
+
const wait = (this.options.submitRetryMs ?? 1_000) * 2 ** (attempts - 1);
|
|
292
|
+
this.retryAt.set(task.id, Date.now() + wait);
|
|
293
|
+
this.scheduleRetry(wait);
|
|
294
|
+
this.options.logger.warn('Task submission failed; retrying', { task_id: task.id, attempts, error: errorText(error) });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
this.failTask(task, `Magnus could not start the turn: ${errorText(error)}`);
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
this.submitting.delete(task.id);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
scheduleRetry(ms) {
|
|
305
|
+
if (this.stopped)
|
|
306
|
+
return;
|
|
307
|
+
if (this.retryTimer)
|
|
308
|
+
clearTimeout(this.retryTimer);
|
|
309
|
+
this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.kick(); }, ms + 5);
|
|
310
|
+
this.retryTimer.unref();
|
|
311
|
+
}
|
|
312
|
+
compose(task, context, conversationId, lastSeen, firstTurn) {
|
|
313
|
+
const { project, revision, role, bindingId } = context;
|
|
314
|
+
const conversation = this.store.room.conversation(conversationId);
|
|
315
|
+
const trigger = task.trigger_message_id ? this.store.room.message(task.trigger_message_id) : undefined;
|
|
316
|
+
const roleNames = new Map(revision.body.roles.map(item => [item.key, item.name]));
|
|
317
|
+
const names = {
|
|
318
|
+
role: (key) => roleNames.get(key) ?? key,
|
|
319
|
+
author: (message) => message.author.kind === 'user' ? message.author.user_id
|
|
320
|
+
: message.author.kind === 'agent' ? (message.author.role_key ? roleNames.get(message.author.role_key) ?? message.author.role_key : 'Agent') : 'Studio',
|
|
321
|
+
};
|
|
322
|
+
const own = (message) => message.author.kind === 'agent' && message.author.binding_id === bindingId;
|
|
323
|
+
let unseen = [];
|
|
324
|
+
let discussion;
|
|
325
|
+
if (conversation.kind === 'discussion' && conversation.discussion) {
|
|
326
|
+
const transcript = this.store.room.recentMessages(conversation.id, ROOM_HISTORY);
|
|
327
|
+
discussion = { ...conversation.discussion, summary: task.source.summary === true, transcript };
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
unseen = this.store.room.messages(conversationId, lastSeen, 500).filter(message => message.id !== trigger?.id && !own(message)).slice(-ROOM_HISTORY);
|
|
331
|
+
}
|
|
332
|
+
const attachments = (trigger?.attachments ?? []).flatMap(id => {
|
|
333
|
+
const file = this.store.files.get(id);
|
|
334
|
+
return file ? [this.attachmentText(file)] : [];
|
|
335
|
+
});
|
|
336
|
+
const extra = this.contextProviders.flatMap(provider => provider(task));
|
|
337
|
+
const composed = composeTurn({ project, team: revision.body, role, task, firstTurn, unseen, trigger, names, attachments, discussion, extra });
|
|
338
|
+
const seen = [...unseen, ...(trigger ? [trigger] : []), ...(discussion?.transcript ?? [])].reduce((max, message) => Math.max(max, message.seq), lastSeen);
|
|
339
|
+
return { composed, seenSeq: seen };
|
|
340
|
+
}
|
|
341
|
+
/** Text attachments are read synchronously only when small; others are named, not inlined. */
|
|
342
|
+
attachmentText(file) {
|
|
343
|
+
if (!TEXT_MIME.test(file.mime) || file.size > ATTACHMENT_TEXT_LIMIT)
|
|
344
|
+
return { name: file.name, mime: file.mime, text: null };
|
|
345
|
+
try {
|
|
346
|
+
const text = this.options.blobs.readTextSync(file.sha256);
|
|
347
|
+
return { name: file.name, mime: file.mime, text };
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return { name: file.name, mime: file.mime, text: null };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** A task that could not start: record why, in the task and in the room. */
|
|
354
|
+
failTask(task, reason) {
|
|
355
|
+
this.retryAt.delete(task.id);
|
|
356
|
+
this.store.tx(() => {
|
|
357
|
+
const current = this.store.tasks.get(task.id);
|
|
358
|
+
if (!current || FINISHED.includes(current.state))
|
|
359
|
+
return;
|
|
360
|
+
const failed = this.store.tasks.setState(task.id, 'failed', { error: reason, ardent_state: null });
|
|
361
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(failed));
|
|
362
|
+
const handler = this.modes.get(task.mode);
|
|
363
|
+
const conversationId = task.conversation_id ?? this.store.room.teamConversation(task.project_id)?.id;
|
|
364
|
+
if (handler)
|
|
365
|
+
handler.finished(failed, { state: 'failed', reply: null, error: reason });
|
|
366
|
+
else if (conversationId)
|
|
367
|
+
this.systemMessage(conversationId, `${this.roleName(task)}: ${task.title}. ${reason}`, { task_id: task.id }, task.trigger_message_id);
|
|
368
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
369
|
+
});
|
|
370
|
+
this.afterFinish(task);
|
|
371
|
+
}
|
|
372
|
+
cancelLocally(task, reason) {
|
|
373
|
+
this.store.tx(() => {
|
|
374
|
+
const current = this.store.tasks.get(task.id);
|
|
375
|
+
if (!current || FINISHED.includes(current.state))
|
|
376
|
+
return;
|
|
377
|
+
const cancelled = this.store.tasks.setState(task.id, 'cancelled', { error: null, ardent_state: null, note: reason });
|
|
378
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(cancelled));
|
|
379
|
+
this.modes.get(task.mode)?.finished(cancelled, { state: 'cancelled', reply: null, error: reason });
|
|
380
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
381
|
+
});
|
|
382
|
+
this.afterFinish(task);
|
|
383
|
+
}
|
|
384
|
+
// ── Following ──
|
|
385
|
+
follow(task) {
|
|
386
|
+
if (this.stopped || this.followers.has(task.id) || !task.ardent.agent_ref || !task.ardent.task_id)
|
|
387
|
+
return;
|
|
388
|
+
const ref = task.ardent.agent_ref;
|
|
389
|
+
const follower = new TaskFollower(this.magnus, ref, task.ardent.task_id, {
|
|
390
|
+
cursor: () => this.store.tasks.get(task.id)?.ardent.event_cursor ?? 0,
|
|
391
|
+
text: (step, text, mode) => this.relay(task.id, step, text, mode),
|
|
392
|
+
event: event => this.journal(task.id, event),
|
|
393
|
+
done: record => this.finish(task.id, record),
|
|
394
|
+
}, this.options.logger, this.options.follower);
|
|
395
|
+
this.followers.set(task.id, follower);
|
|
396
|
+
void follower.start().finally(() => { if (this.followers.get(task.id) === follower)
|
|
397
|
+
this.followers.delete(task.id); });
|
|
398
|
+
}
|
|
399
|
+
/** Live reply text: create the streaming message on the first visible text, then relay it. */
|
|
400
|
+
relay(taskId, step, text, mode) {
|
|
401
|
+
const task = this.store.tasks.get(taskId);
|
|
402
|
+
// Handled modes do not speak in the room; their reply is read when the task finishes.
|
|
403
|
+
if (!task?.ardent.task_id || FINISHED.includes(task.state) || this.modes.has(task.mode))
|
|
404
|
+
return;
|
|
405
|
+
let message = this.store.room.messageForArdentTask(task.ardent.task_id);
|
|
406
|
+
if (!message) {
|
|
407
|
+
if (!text)
|
|
408
|
+
return;
|
|
409
|
+
const conversationId = task.conversation_id ?? this.store.room.teamConversation(task.project_id)?.id;
|
|
410
|
+
if (!conversationId)
|
|
411
|
+
return;
|
|
412
|
+
message = this.store.tx(() => {
|
|
413
|
+
const created = this.store.room.addMessage({ conversationId, author: this.agentAuthor(task), kind: task.source.summary === true ? 'summary' : 'reply',
|
|
414
|
+
bodyMd: '', replyTo: task.trigger_message_id, links: { task_id: task.id }, ardent: { task_id: task.ardent.task_id, session_id: task.ardent.session_id }, state: 'streaming' });
|
|
415
|
+
this.store.emit(task.project_id, 'message.created', this.messageEvent(created));
|
|
416
|
+
return created;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (message.state !== 'streaming')
|
|
420
|
+
return;
|
|
421
|
+
if (mode === 'replace')
|
|
422
|
+
this.options.live.replace(task.project_id, message.id, step, text);
|
|
423
|
+
else
|
|
424
|
+
this.options.live.append(task.project_id, message.id, step, text);
|
|
425
|
+
}
|
|
426
|
+
/** Handle one journal event and advance the cursor in the same transaction. True when it ends the turn. */
|
|
427
|
+
async journal(taskId, event) {
|
|
428
|
+
const task = this.store.tasks.get(taskId);
|
|
429
|
+
if (!task || event.sequence <= task.ardent.event_cursor)
|
|
430
|
+
return false;
|
|
431
|
+
let ended = false;
|
|
432
|
+
let dispatch = false;
|
|
433
|
+
this.store.tx(() => {
|
|
434
|
+
switch (event.kind) {
|
|
435
|
+
case 'state': {
|
|
436
|
+
const to = stateSchema.safeParse(event.detail).data?.to;
|
|
437
|
+
if (to === 'running' && (task.state === 'queued')) {
|
|
438
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(this.store.tasks.setState(task.id, 'running')));
|
|
439
|
+
}
|
|
440
|
+
if (to === 'input_required')
|
|
441
|
+
ended = true;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'model_started':
|
|
445
|
+
if (!FINISHED.includes(task.state))
|
|
446
|
+
this.store.tasks.noteProgress(task.id, { model_started: true });
|
|
447
|
+
break;
|
|
448
|
+
case 'tool_attempt': {
|
|
449
|
+
const tool = toolSchema.safeParse(event.detail).data?.name;
|
|
450
|
+
if (tool && !FINISHED.includes(task.state))
|
|
451
|
+
this.store.tasks.noteProgress(task.id, { model_started: true, tool });
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case 'team_handoff':
|
|
455
|
+
dispatch = this.handoff(task, event);
|
|
456
|
+
break;
|
|
457
|
+
case 'team_ask_user':
|
|
458
|
+
this.clarification(task, event);
|
|
459
|
+
break;
|
|
460
|
+
case 'team_propose_decision':
|
|
461
|
+
this.proposal(task, event);
|
|
462
|
+
break;
|
|
463
|
+
case 'pause_requested':
|
|
464
|
+
if (!FINISHED.includes(task.state))
|
|
465
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(this.store.tasks.setState(task.id, 'pausing')));
|
|
466
|
+
break;
|
|
467
|
+
case 'paused':
|
|
468
|
+
if (!FINISHED.includes(task.state))
|
|
469
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(this.store.tasks.setState(task.id, 'paused')));
|
|
470
|
+
break;
|
|
471
|
+
case 'resumed':
|
|
472
|
+
if (!FINISHED.includes(task.state))
|
|
473
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(this.store.tasks.setState(task.id, 'running')));
|
|
474
|
+
break;
|
|
475
|
+
default: break;
|
|
476
|
+
}
|
|
477
|
+
for (const hook of this.hooks)
|
|
478
|
+
hook(task, event);
|
|
479
|
+
this.store.tasks.advanceCursor(task.id, event.sequence);
|
|
480
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
481
|
+
});
|
|
482
|
+
if (dispatch)
|
|
483
|
+
this.kick();
|
|
484
|
+
if (ended && task.ardent.agent_ref && task.ardent.task_id) {
|
|
485
|
+
const record = await this.magnus.task(task.ardent.agent_ref, task.ardent.task_id).catch(() => undefined);
|
|
486
|
+
if (record && ARDENT_FINISHED.includes(record.state)) {
|
|
487
|
+
await this.finish(task.id, record);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
/** An agent asked for a hand-off. Studio starts it only along a delegates_to or hands_off_to edge of the task's team revision. */
|
|
494
|
+
handoff(task, event) {
|
|
495
|
+
const request = handoffSchema.safeParse(event.detail);
|
|
496
|
+
const conversationId = task.conversation_id ?? this.store.room.teamConversation(task.project_id)?.id;
|
|
497
|
+
if (!request.success || !conversationId)
|
|
498
|
+
return false;
|
|
499
|
+
const revision = task.assignment_revision ? this.store.team.revision(task.project_id, task.assignment_revision) : this.store.team.current(task.project_id);
|
|
500
|
+
const from = this.roleName(task);
|
|
501
|
+
const asked = request.data.to_role.trim();
|
|
502
|
+
const target = revision?.body.roles.find(role => role.key === asked || role.name.toLowerCase() === asked.toLowerCase());
|
|
503
|
+
const refuse = (why) => {
|
|
504
|
+
this.systemMessage(conversationId, `${from} asked to hand work to ${target?.name ?? JSON.stringify(asked)} (${excerpt(request.data.objective, 120)}). ${why}`, { task_id: task.id, handoff: { from_role: task.role_key, to_role: target?.key ?? asked, objective: request.data.objective, allowed: false } }, task.trigger_message_id);
|
|
505
|
+
return false;
|
|
506
|
+
};
|
|
507
|
+
if (!revision)
|
|
508
|
+
return refuse('The project has no published team, so Studio did not start it.');
|
|
509
|
+
if (!target)
|
|
510
|
+
return refuse(`Team r${revision.revision} has no such role, so Studio did not start it.`);
|
|
511
|
+
const edge = revision.body.relationships.find(item => item.from === task.role_key && item.to === target.key && HANDOFF_EDGES.includes(item.type));
|
|
512
|
+
if (!edge) {
|
|
513
|
+
return refuse(`Team r${revision.revision} has no delegates_to or hands_off_to relationship from ${from} to ${target.name}, so Studio did not start it. `
|
|
514
|
+
+ 'A person can assign the work from the Tasks drawer or change the team.');
|
|
515
|
+
}
|
|
516
|
+
if (!target.agent_ref)
|
|
517
|
+
return refuse(`${target.name} has no bound agent in team r${revision.revision}, so Studio did not start it.`);
|
|
518
|
+
const instructions = [request.data.objective, request.data.deliverables?.length ? `Deliverables: ${request.data.deliverables.join('; ')}` : '',
|
|
519
|
+
request.data.note ? `Note from ${from}: ${request.data.note}` : ''].filter(Boolean).join('\n\n');
|
|
520
|
+
const created = this.store.tasks.create({
|
|
521
|
+
projectId: task.project_id, title: excerpt(request.data.objective, 200), mode: 'assignment', roleKey: target.key,
|
|
522
|
+
bindingId: target.binding_id ?? this.store.team.ensureBinding(target.agent_ref).id, assignmentRevision: revision.revision,
|
|
523
|
+
source: { handoff_from: task.role_key, from_task: task.id, ardent_event_seq: event.sequence, relationship: edge.type },
|
|
524
|
+
createdBy: 'conductor', conversationId, instructionsMd: instructions,
|
|
525
|
+
});
|
|
526
|
+
this.store.emit(task.project_id, 'task.created', this.taskEvent(created));
|
|
527
|
+
const message = this.store.room.addMessage({ conversationId, author: this.agentAuthor(task), kind: 'handoff', addressees: [target.key],
|
|
528
|
+
bodyMd: `Handed off to ${target.name}: ${request.data.objective}`, replyTo: task.trigger_message_id,
|
|
529
|
+
links: { task_id: task.id, handoff: { from_role: task.role_key, to_role: target.key, task_id: created.id, relationship: edge.type, allowed: true } }, state: 'complete' });
|
|
530
|
+
this.store.emit(task.project_id, 'message.created', this.messageEvent(message));
|
|
531
|
+
this.refreshActivity(task.project_id, created.binding_id);
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
clarification(task, event) {
|
|
535
|
+
const asked = askSchema.safeParse(event.detail);
|
|
536
|
+
const conversationId = task.conversation_id ?? this.store.room.teamConversation(task.project_id)?.id;
|
|
537
|
+
if (!asked.success || !conversationId)
|
|
538
|
+
return;
|
|
539
|
+
const message = this.store.room.addMessage({ conversationId, author: this.agentAuthor(task), kind: 'clarification', bodyMd: asked.data.question,
|
|
540
|
+
replyTo: task.trigger_message_id, links: { task_id: task.id, question: asked.data.question, options: asked.data.options ?? [], answer_message_id: null }, state: 'complete' });
|
|
541
|
+
this.store.emit(task.project_id, 'message.created', this.messageEvent(message));
|
|
542
|
+
const outcome = (task.outcome && typeof task.outcome === 'object' ? task.outcome : {});
|
|
543
|
+
this.store.tasks.setState(task.id, task.state, { ...outcome, clarification_message_id: message.id });
|
|
544
|
+
}
|
|
545
|
+
proposal(task, event) {
|
|
546
|
+
const proposal = proposalSchema.safeParse(event.detail);
|
|
547
|
+
const conversationId = task.conversation_id ?? this.store.room.teamConversation(task.project_id)?.id;
|
|
548
|
+
if (!proposal.success || !conversationId)
|
|
549
|
+
return;
|
|
550
|
+
// Pinning stays with a person: this is only a proposal card.
|
|
551
|
+
const message = this.store.room.addMessage({ conversationId, author: this.agentAuthor(task), kind: 'decision_proposal', bodyMd: proposal.data.statement,
|
|
552
|
+
replyTo: task.trigger_message_id, links: { task_id: task.id, proposal: { title: proposal.data.title, statement: proposal.data.statement, rationale: proposal.data.rationale ?? '' }, decision_id: null },
|
|
553
|
+
state: 'complete' });
|
|
554
|
+
this.store.emit(task.project_id, 'message.created', this.messageEvent(message));
|
|
555
|
+
}
|
|
556
|
+
/** The task finished in Ardent: write the final message and the task state. Idempotent on the Ardent task id. */
|
|
557
|
+
async finish(taskId, record) {
|
|
558
|
+
const task = this.store.tasks.get(taskId);
|
|
559
|
+
if (!task || !task.ardent.task_id || FINISHED.includes(task.state))
|
|
560
|
+
return;
|
|
561
|
+
// The stream may end before every journal event was sent: read the rest first.
|
|
562
|
+
if (task.ardent.agent_ref) {
|
|
563
|
+
const rest = await this.magnus.taskEvents(task.ardent.agent_ref, task.ardent.task_id, task.ardent.event_cursor).catch(() => []);
|
|
564
|
+
for (const event of rest)
|
|
565
|
+
await this.journal(taskId, event);
|
|
566
|
+
}
|
|
567
|
+
this.complete(taskId, record);
|
|
568
|
+
}
|
|
569
|
+
/** Exposed for routes that receive a final task record directly (cancel). */
|
|
570
|
+
complete(taskId, record) {
|
|
571
|
+
const task = this.store.tasks.get(taskId);
|
|
572
|
+
if (!task || !task.ardent.task_id || FINISHED.includes(task.state))
|
|
573
|
+
return;
|
|
574
|
+
const result = typeof record.result === 'string' ? record.result : null;
|
|
575
|
+
let teamResult;
|
|
576
|
+
if (task.dispatch === 'mission' && result) {
|
|
577
|
+
try {
|
|
578
|
+
teamResult = teamResultSchema.safeParse(JSON.parse(result)).data;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
teamResult = undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const outcome = (task.outcome && typeof task.outcome === 'object' ? task.outcome : {});
|
|
585
|
+
const asked = typeof outcome.clarification_message_id === 'string' || teamResult?.outcome === 'input_required';
|
|
586
|
+
const ardentState = record.state;
|
|
587
|
+
const state = ardentState === 'missing' ? 'unknown'
|
|
588
|
+
: (ardentState === 'completed' || ardentState === 'partial') ? (asked ? 'input_required' : ardentState)
|
|
589
|
+
: ['failed', 'cancelled', 'unknown', 'input_required'].find(item => item === ardentState) ?? 'unknown';
|
|
590
|
+
const answer = (teamResult?.summary ?? result ?? '').trim();
|
|
591
|
+
// Ardent summarizes a turn that ended with team_ask_user as the question itself, which the
|
|
592
|
+
// clarification card already shows: posting it again as a reply would repeat the question.
|
|
593
|
+
const question = typeof outcome.clarification_message_id === 'string' ? this.store.room.message(outcome.clarification_message_id)?.body_md.trim() : undefined;
|
|
594
|
+
const reply = asked && question !== undefined && answer === question ? '' : answer;
|
|
595
|
+
const error = typeof record.error === 'string' && record.error ? record.error.slice(0, 2000) : null;
|
|
596
|
+
const handler = this.modes.get(task.mode);
|
|
597
|
+
if (handler) {
|
|
598
|
+
this.completeHandled(task, handler, { ardentState, reply, error, outcome });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
let finishedMessage;
|
|
602
|
+
this.store.tx(() => {
|
|
603
|
+
const current = this.store.tasks.get(taskId);
|
|
604
|
+
if (FINISHED.includes(current.state))
|
|
605
|
+
return;
|
|
606
|
+
const conversationId = task.conversation_id ?? this.store.room.teamConversation(task.project_id)?.id;
|
|
607
|
+
const existing = this.store.room.messageForArdentTask(task.ardent.task_id);
|
|
608
|
+
const failed = state === 'failed' || state === 'unknown' || state === 'cancelled';
|
|
609
|
+
const body = reply || (state === 'cancelled' ? 'The task was cancelled.' : failed ? `The task ${state === 'failed' ? 'failed' : 'ended in an unknown state'}${error ? `: ${error}` : '.'}` : '');
|
|
610
|
+
if (existing) {
|
|
611
|
+
if (existing.state === 'streaming') {
|
|
612
|
+
finishedMessage = this.store.room.finishMessage(existing.id, { bodyMd: body, state: failed && !reply ? 'failed' : 'complete' });
|
|
613
|
+
this.store.emit(task.project_id, 'message.updated', this.messageEvent(finishedMessage));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
else if (conversationId && body) {
|
|
617
|
+
finishedMessage = this.store.room.addMessage({ conversationId, author: this.agentAuthor(task), kind: task.source.summary === true ? 'summary' : 'reply', bodyMd: body,
|
|
618
|
+
replyTo: task.trigger_message_id, links: { task_id: task.id }, ardent: { task_id: task.ardent.task_id, session_id: task.ardent.session_id },
|
|
619
|
+
state: failed && !reply ? 'failed' : 'complete' });
|
|
620
|
+
this.store.emit(task.project_id, 'message.created', this.messageEvent(finishedMessage));
|
|
621
|
+
}
|
|
622
|
+
const finished = this.store.tasks.setState(taskId, state, {
|
|
623
|
+
...outcome, ardent_state: ardentState, error, reply_message_id: finishedMessage?.id ?? existing?.id ?? null,
|
|
624
|
+
...(teamResult ? { team_result: { outcome: teamResult.outcome, artifacts: teamResult.artifacts?.length ?? 0 } } : {}),
|
|
625
|
+
});
|
|
626
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(finished));
|
|
627
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
628
|
+
});
|
|
629
|
+
const live = finishedMessage ?? this.store.room.messageForArdentTask(task.ardent.task_id);
|
|
630
|
+
if (live)
|
|
631
|
+
this.options.live.clear(live.id);
|
|
632
|
+
this.afterFinish(task);
|
|
633
|
+
}
|
|
634
|
+
/** A handled mode has no clarifications: Ardent input_required ends it as partial, and the handler reads the reply. */
|
|
635
|
+
completeHandled(task, handler, input) {
|
|
636
|
+
const { ardentState } = input;
|
|
637
|
+
const state = ardentState === 'completed' || ardentState === 'partial' ? ardentState
|
|
638
|
+
: ardentState === 'input_required' ? 'partial'
|
|
639
|
+
: ardentState === 'failed' || ardentState === 'cancelled' ? ardentState : 'unknown';
|
|
640
|
+
this.store.tx(() => {
|
|
641
|
+
if (FINISHED.includes(this.store.tasks.get(task.id).state))
|
|
642
|
+
return;
|
|
643
|
+
const finished = this.store.tasks.setState(task.id, state, { ...input.outcome, ardent_state: ardentState, error: input.error });
|
|
644
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(finished));
|
|
645
|
+
handler.finished(finished, { state, reply: input.reply || null, error: input.error });
|
|
646
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
647
|
+
});
|
|
648
|
+
this.afterFinish(task);
|
|
649
|
+
}
|
|
650
|
+
afterFinish(task) {
|
|
651
|
+
if (task.mode === 'discussion_turn' && task.conversation_id)
|
|
652
|
+
this.advanceDiscussion(task.conversation_id);
|
|
653
|
+
this.kick();
|
|
654
|
+
}
|
|
655
|
+
// ── Requests from people (routes call these inside or after their transaction) ──
|
|
656
|
+
/** The project's team conversation, created on first use. Call inside a transaction. */
|
|
657
|
+
ensureTeamConversation(projectId, createdBy) {
|
|
658
|
+
const existing = this.store.room.teamConversation(projectId);
|
|
659
|
+
if (existing)
|
|
660
|
+
return existing;
|
|
661
|
+
return this.store.tx(() => {
|
|
662
|
+
const created = this.store.room.createConversation({ projectId, kind: 'team', title: 'Team room', createdBy });
|
|
663
|
+
this.store.emit(projectId, 'conversation.created', { conversation: created });
|
|
664
|
+
return created;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Queue one turn per addressee in an ordering group, so each addressee sees the replies before
|
|
669
|
+
* theirs. Call inside the transaction that stored the message.
|
|
670
|
+
*/
|
|
671
|
+
queueTurns(input) {
|
|
672
|
+
const groupId = `${input.message.id}:turns`;
|
|
673
|
+
return input.roles.map((role, position) => {
|
|
674
|
+
const task = this.store.tasks.create({
|
|
675
|
+
projectId: input.project.id, title: excerpt(input.message.body_md || 'Reply', 120), mode: input.mode ?? 'turn', roleKey: role.key,
|
|
676
|
+
bindingId: role.binding_id ?? (role.agent_ref ? this.store.team.ensureBinding(role.agent_ref).id : null), assignmentRevision: input.revision.revision,
|
|
677
|
+
source: { message_id: input.message.id, ...input.source }, createdBy: input.createdBy, conversationId: input.conversation.id,
|
|
678
|
+
triggerMessageId: input.message.id, groupId: input.roles.length > 1 ? groupId : null, groupPosition: position,
|
|
679
|
+
});
|
|
680
|
+
this.store.emit(input.project.id, 'task.created', this.taskEvent(task));
|
|
681
|
+
this.refreshActivity(input.project.id, task.binding_id);
|
|
682
|
+
return task;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
/** Queue one task (an assignment, follow-up or answer). Call inside a transaction. */
|
|
686
|
+
queueTask(input) {
|
|
687
|
+
const task = this.store.tasks.create(input);
|
|
688
|
+
this.store.emit(input.projectId, 'task.created', this.taskEvent(task));
|
|
689
|
+
this.refreshActivity(input.projectId, task.binding_id);
|
|
690
|
+
return task;
|
|
691
|
+
}
|
|
692
|
+
/** Queue round `round` of a discussion: one turn per participant, in order. Call inside a transaction. */
|
|
693
|
+
queueDiscussionRound(conversation, revision, round, createdBy) {
|
|
694
|
+
const discussion = conversation.discussion;
|
|
695
|
+
return discussion.participants.flatMap((key, position) => {
|
|
696
|
+
const role = revision.body.roles.find(item => item.key === key);
|
|
697
|
+
if (!role)
|
|
698
|
+
return [];
|
|
699
|
+
return [this.queueTask({ projectId: conversation.project_id, title: `${excerpt(discussion.question, 80)} (round ${round})`, mode: 'discussion_turn',
|
|
700
|
+
roleKey: key, bindingId: role.binding_id ?? (role.agent_ref ? this.store.team.ensureBinding(role.agent_ref).id : null), assignmentRevision: revision.revision,
|
|
701
|
+
source: { discussion: conversation.id, round }, createdBy, conversationId: conversation.id, groupId: `${conversation.id}:r${round}`, groupPosition: position })];
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
/** Move a discussion on once the current round (or its summary) has finished. */
|
|
705
|
+
advanceDiscussion(conversationId) {
|
|
706
|
+
const conversation = this.store.room.conversation(conversationId);
|
|
707
|
+
const discussion = conversation?.discussion;
|
|
708
|
+
if (!conversation || !discussion || conversation.stopped_at || (discussion.state !== 'running' && discussion.state !== 'summarizing'))
|
|
709
|
+
return;
|
|
710
|
+
const group = discussion.state === 'running' ? `${conversation.id}:r${discussion.round}` : `${conversation.id}:summary`;
|
|
711
|
+
if (this.store.tasks.inGroup(group).some(task => !FINISHED.includes(task.state)))
|
|
712
|
+
return;
|
|
713
|
+
const revision = this.store.team.current(conversation.project_id);
|
|
714
|
+
this.store.tx(() => {
|
|
715
|
+
let next;
|
|
716
|
+
if (discussion.state === 'running' && discussion.round < discussion.max_rounds && revision) {
|
|
717
|
+
next = { ...discussion, round: discussion.round + 1 };
|
|
718
|
+
const updated = this.store.room.updateDiscussion(conversation.id, next);
|
|
719
|
+
this.queueDiscussionRound(updated, revision, next.round, 'conductor');
|
|
720
|
+
}
|
|
721
|
+
else if (discussion.state === 'running') {
|
|
722
|
+
const coordinator = revision?.body.roles.find(role => role.coordinator && role.agent_ref);
|
|
723
|
+
if (coordinator && revision) {
|
|
724
|
+
next = { ...discussion, state: 'summarizing' };
|
|
725
|
+
this.store.room.updateDiscussion(conversation.id, next);
|
|
726
|
+
this.queueTask({ projectId: conversation.project_id, title: `Summary: ${excerpt(discussion.question, 80)}`, mode: 'discussion_turn', roleKey: coordinator.key,
|
|
727
|
+
bindingId: coordinator.binding_id ?? this.store.team.ensureBinding(coordinator.agent_ref).id, assignmentRevision: revision.revision,
|
|
728
|
+
source: { discussion: conversation.id, summary: true }, createdBy: 'conductor', conversationId: conversation.id, groupId: `${conversation.id}:summary` });
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
next = { ...discussion, state: 'done' };
|
|
732
|
+
this.store.room.updateDiscussion(conversation.id, next);
|
|
733
|
+
this.systemMessage(conversation.id, 'The discussion ended without a summary: the team has no bound coordinator.', {});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
next = { ...discussion, state: 'done' };
|
|
738
|
+
this.store.room.updateDiscussion(conversation.id, next);
|
|
739
|
+
}
|
|
740
|
+
this.store.emit(conversation.project_id, 'conversation.updated', { conversation: this.store.room.conversation(conversation.id) });
|
|
741
|
+
});
|
|
742
|
+
this.kick();
|
|
743
|
+
}
|
|
744
|
+
runningDiscussions() {
|
|
745
|
+
const rows = this.store.db.prepare("SELECT id FROM conversations WHERE kind = 'discussion' AND stopped_at IS NULL").all();
|
|
746
|
+
return rows.map(row => this.store.room.conversation(row.id)).filter(item => item.discussion?.state === 'running' || item.discussion?.state === 'summarizing');
|
|
747
|
+
}
|
|
748
|
+
/** Stop a conversation: unstarted turns are cancelled here, started ones are cancelled through Magnus. */
|
|
749
|
+
async stopConversation(conversationId) {
|
|
750
|
+
const tasks = this.store.tasks.inConversation(conversationId).filter(task => !FINISHED.includes(task.state));
|
|
751
|
+
for (const task of tasks) {
|
|
752
|
+
if (!task.ardent.task_id)
|
|
753
|
+
this.cancelLocally(task, 'The conversation was stopped.');
|
|
754
|
+
else
|
|
755
|
+
await this.cancel(task).catch(error => this.options.logger.warn('Cancel on stop failed', { task_id: task.id, error: errorText(error) }));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** Cancel a task. An unstarted one is cancelled here; a started one through Magnus, and its final record follows. */
|
|
759
|
+
async cancel(task) {
|
|
760
|
+
if (FINISHED.includes(task.state))
|
|
761
|
+
throw new ConductorError('The task has already finished', 409, 'TASK_FINISHED');
|
|
762
|
+
if (!task.ardent.task_id || !task.ardent.agent_ref) {
|
|
763
|
+
this.cancelLocally(task, 'Cancelled before it started.');
|
|
764
|
+
return this.store.tasks.get(task.id);
|
|
765
|
+
}
|
|
766
|
+
const record = await this.magnusCall(() => this.magnus.cancelTask(task.ardent.agent_ref, task.ardent.task_id));
|
|
767
|
+
const parsed = z.object({ id: z.string(), state: z.string(), result: z.string().nullable().optional(), error: z.string().nullable().optional() }).loose().safeParse(record);
|
|
768
|
+
if (parsed.success && ARDENT_FINISHED.includes(parsed.data.state))
|
|
769
|
+
await this.finish(task.id, parsed.data);
|
|
770
|
+
return this.store.tasks.get(task.id);
|
|
771
|
+
}
|
|
772
|
+
/** Pause a task at its next step boundary (studio >= 2), or hold an unstarted one. */
|
|
773
|
+
async pause(task) {
|
|
774
|
+
if (FINISHED.includes(task.state))
|
|
775
|
+
throw new ConductorError('The task has already finished', 409, 'TASK_FINISHED');
|
|
776
|
+
if (task.state === 'paused' || task.state === 'pausing')
|
|
777
|
+
return task;
|
|
778
|
+
if (!task.ardent.task_id || !task.ardent.agent_ref) {
|
|
779
|
+
return this.store.tx(() => {
|
|
780
|
+
const held = this.store.tasks.setState(task.id, 'paused', { ...(task.outcome ?? {}), held: true });
|
|
781
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(held));
|
|
782
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
783
|
+
return held;
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
await this.magnusCall(() => this.magnus.pauseTask(task.ardent.agent_ref, task.ardent.task_id));
|
|
787
|
+
return this.store.tx(() => {
|
|
788
|
+
const current = this.store.tasks.get(task.id);
|
|
789
|
+
if (current.state === 'paused' || FINISHED.includes(current.state))
|
|
790
|
+
return current;
|
|
791
|
+
const pausing = this.store.tasks.setState(task.id, 'pausing');
|
|
792
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(pausing));
|
|
793
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
794
|
+
return pausing;
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
async resume(task) {
|
|
798
|
+
if (task.state !== 'paused' && task.state !== 'pausing')
|
|
799
|
+
throw new ConductorError('The task is not paused', 409, 'TASK_NOT_PAUSED');
|
|
800
|
+
if (!task.ardent.task_id || !task.ardent.agent_ref) {
|
|
801
|
+
const queued = this.store.tx(() => {
|
|
802
|
+
const next = this.store.tasks.setState(task.id, 'queued');
|
|
803
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(next));
|
|
804
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
805
|
+
return next;
|
|
806
|
+
});
|
|
807
|
+
this.kick();
|
|
808
|
+
return queued;
|
|
809
|
+
}
|
|
810
|
+
const answer = await this.magnusCall(() => this.magnus.resumeTask(task.ardent.agent_ref, task.ardent.task_id));
|
|
811
|
+
return this.store.tx(() => {
|
|
812
|
+
const current = this.store.tasks.get(task.id);
|
|
813
|
+
if (FINISHED.includes(current.state))
|
|
814
|
+
return current;
|
|
815
|
+
const next = this.store.tasks.setState(task.id, answer.state === 'queued' ? 'queued' : 'running');
|
|
816
|
+
this.store.emit(task.project_id, 'task.updated', this.taskEvent(next));
|
|
817
|
+
this.refreshActivity(task.project_id, task.binding_id);
|
|
818
|
+
return next;
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
async magnusCall(call) {
|
|
822
|
+
try {
|
|
823
|
+
return await call();
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (error instanceof MagnusError) {
|
|
827
|
+
if (error.code === 'AGENT_UPGRADE_REQUIRED')
|
|
828
|
+
throw new ConductorError(error.message, 409, 'AGENT_UPGRADE_REQUIRED');
|
|
829
|
+
if (error.status === 409)
|
|
830
|
+
throw new ConductorError(`Ardent refused: ${error.message}`, 409, 'ARDENT_CONFLICT');
|
|
831
|
+
if (error.status === 404)
|
|
832
|
+
throw new ConductorError('Ardent has no record of this task', 404, 'ARDENT_TASK_NOT_FOUND');
|
|
833
|
+
}
|
|
834
|
+
throw new ConductorError(`Magnus could not reach the agent: ${errorText(error)}`, 503, 'MAGNUS_UNAVAILABLE');
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// ── Presence and activity ──
|
|
838
|
+
/** Probe agent status every 30 s. The probe only records reachability; activity is then derived as for any change. */
|
|
839
|
+
async probe() {
|
|
840
|
+
if (this.stopped)
|
|
841
|
+
return;
|
|
842
|
+
let profiles;
|
|
843
|
+
try {
|
|
844
|
+
profiles = await this.options.agents.profiles();
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
profiles = undefined;
|
|
848
|
+
}
|
|
849
|
+
const byRef = new Map((profiles ?? []).map(profile => [`${profile.nodeId}~${profile.id}`, profile]));
|
|
850
|
+
for (const project of this.store.projects.list({ status: 'active' })) {
|
|
851
|
+
const revision = this.store.team.current(project.id);
|
|
852
|
+
if (!revision)
|
|
853
|
+
continue;
|
|
854
|
+
this.store.tx(() => {
|
|
855
|
+
for (const role of revision.body.roles) {
|
|
856
|
+
if (!role.agent_ref)
|
|
857
|
+
continue;
|
|
858
|
+
const profile = byRef.get(role.agent_ref);
|
|
859
|
+
this.presence.set(role.agent_ref, !profiles ? 'unreachable' : profile && isAvailable(profile) ? 'available' : 'offline');
|
|
860
|
+
this.refreshActivity(project.id, role.binding_id ?? this.store.team.ensureBinding(role.agent_ref).id);
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Derive a binding's activity from the last probe and its real records (docs/CONDUCTOR.md#activity),
|
|
867
|
+
* and announce it when it changed. Call inside a transaction, after the records changed.
|
|
868
|
+
*/
|
|
869
|
+
refreshActivity(projectId, bindingId) {
|
|
870
|
+
if (!bindingId)
|
|
871
|
+
return;
|
|
872
|
+
const binding = this.store.db.prepare('SELECT agent_ref FROM agent_bindings WHERE id = ?').get(bindingId);
|
|
873
|
+
if (!binding)
|
|
874
|
+
return;
|
|
875
|
+
const tasks = this.store.tasks.list(projectId).filter(task => task.binding_id === bindingId);
|
|
876
|
+
const now = this.store.clock.now();
|
|
877
|
+
const deliveringMs = this.options.deliveringMs ?? 20_000;
|
|
878
|
+
const delivered = this.store.review.latestDeliveredBy(projectId, bindingId);
|
|
879
|
+
const activity = deriveActivity({
|
|
880
|
+
probe: this.presence.get(binding.agent_ref),
|
|
881
|
+
tasks,
|
|
882
|
+
blockerOf: task => this.store.tasks.groupBefore(task).find(item => !FINISHED.includes(item.state)),
|
|
883
|
+
handedOff: this.store.tasks.handedOffFrom(projectId, new Set(tasks.map(task => task.id))).filter(task => task.binding_id !== bindingId),
|
|
884
|
+
message: id => this.store.room.message(id),
|
|
885
|
+
roleName: task => this.roleName(task),
|
|
886
|
+
delivered, now, deliveringMs,
|
|
887
|
+
});
|
|
888
|
+
this.writeActivity(projectId, bindingId, binding.agent_ref, activity.state, activity.label, activity.source);
|
|
889
|
+
// Delivering is transient: derive again when its window ends.
|
|
890
|
+
const remaining = delivered ? Date.parse(delivered.received_at) + deliveringMs - now.getTime() : 0;
|
|
891
|
+
const key = `${projectId}:${bindingId}`;
|
|
892
|
+
if (remaining > 0 && !this.deliveringTimers.has(key) && !this.stopped) {
|
|
893
|
+
const timer = setTimeout(() => {
|
|
894
|
+
this.deliveringTimers.delete(key);
|
|
895
|
+
if (!this.stopped)
|
|
896
|
+
this.store.tx(() => this.refreshActivity(projectId, bindingId));
|
|
897
|
+
}, remaining + 5);
|
|
898
|
+
timer.unref();
|
|
899
|
+
this.deliveringTimers.set(key, timer);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
writeActivity(projectId, bindingId, agentRef, state, label, source) {
|
|
903
|
+
const since = this.store.clock.now().toISOString();
|
|
904
|
+
if (this.store.room.changeActivity({ project_id: projectId, binding_id: bindingId, state, label, source, since })) {
|
|
905
|
+
this.store.emit(projectId, 'presence.updated', { binding_id: bindingId, agent_ref: agentRef, state, label, source, since });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ── Shared record helpers ──
|
|
909
|
+
agentAuthor(task) {
|
|
910
|
+
return task.binding_id ? { kind: 'agent', binding_id: task.binding_id, role_key: task.role_key } : { kind: 'system' };
|
|
911
|
+
}
|
|
912
|
+
roleName(task) {
|
|
913
|
+
const revision = task.assignment_revision ? this.store.team.revision(task.project_id, task.assignment_revision) : this.store.team.current(task.project_id);
|
|
914
|
+
return revision?.body.roles.find(role => role.key === task.role_key)?.name ?? task.role_key ?? 'An agent';
|
|
915
|
+
}
|
|
916
|
+
/** A Studio note in the room. Call inside a transaction. */
|
|
917
|
+
systemMessage(conversationId, text, links, replyTo) {
|
|
918
|
+
const message = this.store.room.addMessage({ conversationId, author: { kind: 'system' }, kind: 'system', bodyMd: text, replyTo: replyTo ?? null, links, state: 'complete' });
|
|
919
|
+
this.store.emit(message.project_id, 'message.created', this.messageEvent(message));
|
|
920
|
+
return message;
|
|
921
|
+
}
|
|
922
|
+
messageEvent(message) {
|
|
923
|
+
return { message };
|
|
924
|
+
}
|
|
925
|
+
taskEvent(task) {
|
|
926
|
+
return { task: publicTask(task) };
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/** A task as the browser sees it (no mission JSON). */
|
|
930
|
+
export function publicTask(task) {
|
|
931
|
+
return {
|
|
932
|
+
id: task.id, project_id: task.project_id, title: task.title, mode: task.mode, role_key: task.role_key, binding_id: task.binding_id,
|
|
933
|
+
assignment_revision: task.assignment_revision, conversation_id: task.conversation_id, trigger_message_id: task.trigger_message_id,
|
|
934
|
+
instructions_md: task.instructions_md, source: task.source, state: task.state, dispatch: task.dispatch,
|
|
935
|
+
ardent: { agent_ref: task.ardent.agent_ref, task_id: task.ardent.task_id, session_id: task.ardent.session_id },
|
|
936
|
+
outcome: task.outcome ?? null, reassigned_from: task.reassigned_from, group_id: task.group_id, group_position: task.group_position,
|
|
937
|
+
created_by: task.created_by, created_at: task.created_at, updated_at: task.updated_at, finished_at: task.finished_at,
|
|
938
|
+
};
|
|
939
|
+
}
|