@superjackfruitlabs/salaam 0.0.1 → 0.0.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/CHANGELOG.md +15 -0
- package/README.md +25 -17
- package/dist/src/a2a-backend.js +225 -0
- package/dist/src/a2a-inbound.js +487 -0
- package/dist/src/a2a-peer.js +370 -0
- package/dist/src/a2a-protocol.js +250 -0
- package/dist/src/agent-profiles.js +289 -0
- package/dist/src/children.js +132 -26
- package/dist/src/claude-provider.js +2 -179
- package/dist/src/cli-help.js +107 -6
- package/dist/src/cli.js +477 -13
- package/dist/src/config.js +26 -3
- package/dist/src/credentials.js +7 -2
- package/dist/src/external-runtime.js +1 -0
- package/dist/src/external-work.js +583 -0
- package/dist/src/harness-backend.js +466 -0
- package/dist/src/harness-container.js +450 -0
- package/dist/src/harness-model-gateway.js +104 -0
- package/dist/src/harness-prepare.js +88 -0
- package/dist/src/harness-probe.js +111 -0
- package/dist/src/harness-protocol.js +325 -0
- package/dist/src/harness-readiness.js +50 -0
- package/dist/src/harness-rpc.js +206 -0
- package/dist/src/harness-tool-identity.js +87 -0
- package/dist/src/harness-wire-bridge.js +387 -0
- package/dist/src/host.js +6 -1
- package/dist/src/house-extension.js +147 -64
- package/dist/src/live-provider.js +301 -0
- package/dist/src/managed.js +33 -3
- package/dist/src/protocol.js +9 -5
- package/dist/src/provider-auth.js +300 -0
- package/dist/src/provider-config.js +65 -17
- package/dist/src/provider-model.js +78 -0
- package/dist/src/provider-payload.js +264 -0
- package/dist/src/provider-transport.js +36 -0
- package/dist/src/provider-usage.js +59 -0
- package/dist/src/repository-snapshots.js +219 -0
- package/dist/src/service.js +274 -9
- package/dist/src/store.js +44 -2
- package/dist/src/supervisor.js +246 -35
- package/dist/src/task-budget.js +17 -8
- package/dist/src/task-context.js +15 -0
- package/dist/src/task-driver.js +38 -14
- package/dist/src/task-policy.js +20 -1
- package/dist/src/task-verification.js +3 -0
- package/dist/src/task-work.js +31 -0
- package/dist/src/team-work.js +378 -0
- package/dist/src/tui-view.js +65 -10
- package/dist/src/tui.js +4 -2
- package/dist/src/usage.js +18 -0
- package/dist/src/worker-boundary.js +298 -0
- package/dist/src/worker-workspaces.js +274 -0
- package/dist/src/workspaces.js +6 -2
- package/harness-runtime/codex-pins.json +10 -0
- package/harness-runtime/package-lock.json +560 -0
- package/harness-runtime/package.json +9 -0
- package/npm-shrinkwrap.json +57 -2
- package/package.json +2 -1
- package/src/a2a-backend.ts +254 -0
- package/src/a2a-inbound.ts +585 -0
- package/src/a2a-peer.ts +441 -0
- package/src/a2a-protocol.ts +286 -0
- package/src/agent-profiles.ts +379 -0
- package/src/children.ts +168 -27
- package/src/claude-provider.ts +2 -189
- package/src/cli-help.ts +107 -6
- package/src/cli.ts +602 -12
- package/src/config.ts +34 -3
- package/src/credentials.ts +9 -2
- package/src/external-runtime.ts +54 -0
- package/src/external-work.ts +663 -0
- package/src/harness-backend.ts +557 -0
- package/src/harness-container.ts +543 -0
- package/src/harness-model-gateway.ts +138 -0
- package/src/harness-prepare.ts +113 -0
- package/src/harness-probe.ts +150 -0
- package/src/harness-protocol.ts +388 -0
- package/src/harness-readiness.ts +71 -0
- package/src/harness-rpc.ts +244 -0
- package/src/harness-tool-identity.ts +91 -0
- package/src/harness-wire-bridge.ts +418 -0
- package/src/host.ts +11 -6
- package/src/house-extension.ts +165 -66
- package/src/live-provider.ts +347 -0
- package/src/managed.ts +43 -4
- package/src/protocol.ts +36 -8
- package/src/provider-auth.ts +336 -0
- package/src/provider-config.ts +89 -18
- package/src/provider-model.ts +86 -0
- package/src/provider-payload.ts +263 -0
- package/src/provider-transport.ts +51 -0
- package/src/provider-usage.ts +64 -0
- package/src/repository-snapshots.ts +259 -0
- package/src/service.ts +446 -11
- package/src/store.ts +60 -4
- package/src/supervisor.ts +294 -41
- package/src/task-budget.ts +24 -11
- package/src/task-context.ts +17 -0
- package/src/task-driver.ts +51 -15
- package/src/task-policy.ts +23 -3
- package/src/task-verification.ts +2 -0
- package/src/task-work.ts +46 -1
- package/src/team-work.ts +554 -0
- package/src/tui-client.ts +5 -0
- package/src/tui-view.ts +164 -12
- package/src/tui.ts +5 -2
- package/src/usage.ts +17 -0
- package/src/worker-boundary.ts +395 -0
- package/src/worker-workspaces.ts +390 -0
- package/src/workspaces.ts +4 -2
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { digest, canonical, ServiceError } from './protocol.js';
|
|
3
|
+
import { a2aIdentity, a2aObject, type A2AObject } from './a2a-peer.js';
|
|
4
|
+
|
|
5
|
+
export interface A2ACheckpoint {
|
|
6
|
+
schema_version: 1;
|
|
7
|
+
dialect: 'a2a';
|
|
8
|
+
run_id?: string;
|
|
9
|
+
worker_id: string;
|
|
10
|
+
attempt_id: string;
|
|
11
|
+
peer_id: string;
|
|
12
|
+
peer_digest: string;
|
|
13
|
+
tenant: string;
|
|
14
|
+
message_id: string;
|
|
15
|
+
task_id: string | null;
|
|
16
|
+
context_id: string | null;
|
|
17
|
+
state: 'prepared' | 'dispatching' | 'running' | 'terminal' | 'unknown';
|
|
18
|
+
prompt_digest: string;
|
|
19
|
+
cancel_requested: boolean;
|
|
20
|
+
cancel_state?: 'intent' | 'dispatching' | 'acknowledged' | 'unsupported';
|
|
21
|
+
result: A2AObject | null;
|
|
22
|
+
disclosure: { path: string; digest: string; bytes: number }[];
|
|
23
|
+
progress?: { remote_state: string | null; artifact_ids: string[] };
|
|
24
|
+
artifact_receipts?: { id: string; content_digest: string; upstream_ids: string[] }[];
|
|
25
|
+
}
|
|
26
|
+
export interface A2ARpc {
|
|
27
|
+
rpc(method: 'SendMessage' | 'GetTask' | 'CancelTask', params: A2AObject): Promise<A2AObject>;
|
|
28
|
+
}
|
|
29
|
+
export const A2A_TERMINAL = new Set([
|
|
30
|
+
'TASK_STATE_COMPLETED',
|
|
31
|
+
'TASK_STATE_FAILED',
|
|
32
|
+
'TASK_STATE_CANCELED',
|
|
33
|
+
'TASK_STATE_REJECTED',
|
|
34
|
+
]);
|
|
35
|
+
const STATES = new Set([
|
|
36
|
+
...A2A_TERMINAL,
|
|
37
|
+
'TASK_STATE_SUBMITTED',
|
|
38
|
+
'TASK_STATE_WORKING',
|
|
39
|
+
'TASK_STATE_INPUT_REQUIRED',
|
|
40
|
+
'TASK_STATE_AUTH_REQUIRED',
|
|
41
|
+
]);
|
|
42
|
+
export function a2aTask(value: unknown): A2AObject {
|
|
43
|
+
const task = a2aObject(value);
|
|
44
|
+
a2aIdentity(task.id);
|
|
45
|
+
a2aIdentity(task.contextId);
|
|
46
|
+
const status = a2aObject(task.status);
|
|
47
|
+
if (!STATES.has(status.state) || canonical(task).length > 524288)
|
|
48
|
+
throw new ServiceError('a2a_task', 'Unsupported task state or size');
|
|
49
|
+
const artifacts = task.artifacts ?? [],
|
|
50
|
+
ids = new Set<string>();
|
|
51
|
+
if (!Array.isArray(artifacts) || artifacts.length > 64) throw Error('Unbounded remote artifacts');
|
|
52
|
+
for (const artifact of artifacts) {
|
|
53
|
+
const id = a2aIdentity(a2aObject(artifact).artifactId);
|
|
54
|
+
if (ids.has(id)) throw Error('Duplicate remote artifact identity');
|
|
55
|
+
ids.add(id);
|
|
56
|
+
validateParts(artifact.parts);
|
|
57
|
+
}
|
|
58
|
+
// Remote URIs are never fetched, even if authenticated or advertised as files.
|
|
59
|
+
for (const message of task.history ?? []) {
|
|
60
|
+
if (!['ROLE_AGENT', 'ROLE_USER'].includes(message.role))
|
|
61
|
+
throw Error('Unknown remote message role');
|
|
62
|
+
validateParts(message.parts);
|
|
63
|
+
}
|
|
64
|
+
if (status.message) validateParts(status.message.parts);
|
|
65
|
+
return structuredClone(task);
|
|
66
|
+
}
|
|
67
|
+
export function validateParts(value: unknown): void {
|
|
68
|
+
if (!Array.isArray(value) || value.length > 64) throw Error('Unsupported remote parts');
|
|
69
|
+
for (const raw of value) {
|
|
70
|
+
const p = a2aObject(raw),
|
|
71
|
+
keys = Object.keys(p).filter((k) => !['metadata', 'mediaType'].includes(k));
|
|
72
|
+
if (
|
|
73
|
+
keys.length !== 1 ||
|
|
74
|
+
!['text', 'data'].includes(keys[0]!) ||
|
|
75
|
+
(keys[0] === 'text' && typeof p.text !== 'string') ||
|
|
76
|
+
(keys[0] === 'data' && (!p.data || typeof p.data !== 'object' || Array.isArray(p.data)))
|
|
77
|
+
)
|
|
78
|
+
throw Error('Only bounded inline text/data parts are admitted');
|
|
79
|
+
if (
|
|
80
|
+
p.mediaType !== undefined &&
|
|
81
|
+
p.mediaType !== (keys[0] === 'text' ? 'text/plain' : 'application/json')
|
|
82
|
+
)
|
|
83
|
+
throw Error('Unsupported inline part media type');
|
|
84
|
+
if (canonical(p).length > 131072) throw Error('Remote part exceeds bound');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function terminalContent(task: A2AObject): unknown {
|
|
88
|
+
return {
|
|
89
|
+
id: task.id,
|
|
90
|
+
contextId: task.contextId,
|
|
91
|
+
status: {
|
|
92
|
+
state: task.status.state,
|
|
93
|
+
message: task.status.message
|
|
94
|
+
? {
|
|
95
|
+
role: task.status.message.role,
|
|
96
|
+
parts: task.status.message.parts,
|
|
97
|
+
metadata: task.status.message.metadata ?? null,
|
|
98
|
+
}
|
|
99
|
+
: null,
|
|
100
|
+
},
|
|
101
|
+
artifacts: (task.artifacts ?? []).map(({ artifactId: _, ...content }: A2AObject) => content),
|
|
102
|
+
metadata: task.metadata ?? null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Dispatch is never retried. Reconnect reads only the saved peer/task/context. */
|
|
106
|
+
export class A2AInvocation {
|
|
107
|
+
private cp: A2ACheckpoint;
|
|
108
|
+
private broken = false;
|
|
109
|
+
constructor(
|
|
110
|
+
readonly peer: { id: string; tenant: string; digest: string; cancel: boolean; skill: string },
|
|
111
|
+
checkpoint: A2ACheckpoint,
|
|
112
|
+
readonly rpc: A2ARpc,
|
|
113
|
+
readonly save: (value: A2ACheckpoint) => undefined,
|
|
114
|
+
) {
|
|
115
|
+
if (
|
|
116
|
+
checkpoint.dialect !== 'a2a' ||
|
|
117
|
+
checkpoint.schema_version !== 1 ||
|
|
118
|
+
checkpoint.peer_id !== peer.id ||
|
|
119
|
+
checkpoint.peer_digest !== peer.digest ||
|
|
120
|
+
checkpoint.tenant !== peer.tenant
|
|
121
|
+
)
|
|
122
|
+
throw Error('A2A peer ownership changed');
|
|
123
|
+
this.cp = structuredClone(checkpoint);
|
|
124
|
+
if (['dispatching', 'running'].includes(this.cp.state)) this.update({ state: 'unknown' });
|
|
125
|
+
}
|
|
126
|
+
view(): A2ACheckpoint {
|
|
127
|
+
return structuredClone(this.cp);
|
|
128
|
+
}
|
|
129
|
+
private update(change: Partial<A2ACheckpoint>): void {
|
|
130
|
+
if (this.broken) throw Error('A2A checkpoint control failed');
|
|
131
|
+
const next = { ...this.cp, ...change };
|
|
132
|
+
try {
|
|
133
|
+
if (this.save(structuredClone(next)) !== undefined)
|
|
134
|
+
throw Error('A2A checkpoint must commit synchronously');
|
|
135
|
+
} catch (e) {
|
|
136
|
+
this.broken = true;
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
this.cp = next;
|
|
140
|
+
}
|
|
141
|
+
private accept(value: unknown): A2AObject {
|
|
142
|
+
const task = a2aTask(value);
|
|
143
|
+
if (
|
|
144
|
+
(this.cp.task_id && task.id !== this.cp.task_id) ||
|
|
145
|
+
(this.cp.context_id && task.contextId !== this.cp.context_id)
|
|
146
|
+
)
|
|
147
|
+
throw Error('Remote task/context ownership mismatch');
|
|
148
|
+
if (
|
|
149
|
+
this.cp.state === 'terminal' &&
|
|
150
|
+
digest(terminalContent(task)) !== digest(terminalContent(this.cp.result!))
|
|
151
|
+
)
|
|
152
|
+
throw Error('Conflicting remote terminal snapshot');
|
|
153
|
+
const artifact_receipts = (task.artifacts ?? []).map((artifact: A2AObject, index: number) => {
|
|
154
|
+
const { artifactId, ...content } = artifact,
|
|
155
|
+
content_digest = digest(content),
|
|
156
|
+
id = digest({ task_id: task.id, index, content_digest });
|
|
157
|
+
const old = this.cp.artifact_receipts?.find((a) => a.id === id);
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
content_digest,
|
|
161
|
+
upstream_ids: [...new Set([...(old?.upstream_ids ?? []), artifactId])].slice(
|
|
162
|
+
-8,
|
|
163
|
+
) as string[],
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
this.update({
|
|
167
|
+
task_id: task.id,
|
|
168
|
+
context_id: task.contextId,
|
|
169
|
+
result: task,
|
|
170
|
+
artifact_receipts,
|
|
171
|
+
state: A2A_TERMINAL.has(task.status.state) ? 'terminal' : 'running',
|
|
172
|
+
});
|
|
173
|
+
return task;
|
|
174
|
+
}
|
|
175
|
+
async dispatch(text: string, data: A2AObject): Promise<A2AObject> {
|
|
176
|
+
if (
|
|
177
|
+
this.cp.state !== 'prepared' ||
|
|
178
|
+
this.cp.cancel_requested ||
|
|
179
|
+
this.cp.prompt_digest !== digest({ text, data })
|
|
180
|
+
)
|
|
181
|
+
throw Error('A2A invocation cannot dispatch again');
|
|
182
|
+
this.update({ state: 'dispatching' });
|
|
183
|
+
try {
|
|
184
|
+
const result = await this.rpc.rpc('SendMessage', {
|
|
185
|
+
message: {
|
|
186
|
+
messageId: this.cp.message_id,
|
|
187
|
+
role: 'ROLE_USER',
|
|
188
|
+
parts: [{ text }, { data }],
|
|
189
|
+
metadata: { 'salaam.skill': this.peer.skill },
|
|
190
|
+
},
|
|
191
|
+
configuration: {
|
|
192
|
+
acceptedOutputModes: ['text/plain', 'application/json'],
|
|
193
|
+
returnImmediately: true,
|
|
194
|
+
historyLength: 0,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
if (!result.task || result.message) throw Error('A durable remote task receipt is required');
|
|
198
|
+
return this.accept(result.task);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
this.update({ state: 'unknown' });
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async inspect(): Promise<A2AObject> {
|
|
205
|
+
if (!this.cp.task_id || !this.cp.context_id || this.broken)
|
|
206
|
+
throw Error('Remote acceptance identity is unknown; prompt will not be resent');
|
|
207
|
+
const result = await this.rpc.rpc('GetTask', { id: this.cp.task_id, historyLength: 0 });
|
|
208
|
+
return this.accept(result);
|
|
209
|
+
}
|
|
210
|
+
/** Stream chunks are tentative. Reconnect always reconciles an authoritative task snapshot. */
|
|
211
|
+
observe(raw: unknown): void {
|
|
212
|
+
const event = a2aObject(raw),
|
|
213
|
+
keys = Object.keys(event);
|
|
214
|
+
if (keys.length !== 1 || !['task', 'statusUpdate', 'artifactUpdate'].includes(keys[0]!))
|
|
215
|
+
throw new ServiceError('a2a_identity', 'Unsupported stream payload');
|
|
216
|
+
const value = a2aObject(event[keys[0]!]);
|
|
217
|
+
if (
|
|
218
|
+
(keys[0] === 'task' ? value.id : value.taskId) !== this.cp.task_id ||
|
|
219
|
+
value.contextId !== this.cp.context_id
|
|
220
|
+
)
|
|
221
|
+
throw new ServiceError('a2a_identity', 'Unowned stream task/context');
|
|
222
|
+
const progress = structuredClone(this.cp.progress ?? { remote_state: null, artifact_ids: [] });
|
|
223
|
+
if (keys[0] === 'task') {
|
|
224
|
+
const task = a2aTask(value);
|
|
225
|
+
progress.remote_state = task.status.state;
|
|
226
|
+
progress.artifact_ids = task.artifacts?.map((a: A2AObject) => a.artifactId) ?? [];
|
|
227
|
+
} else if (keys[0] === 'statusUpdate') {
|
|
228
|
+
if (!STATES.has(value.status?.state))
|
|
229
|
+
throw new ServiceError('a2a_identity', 'Unknown stream state');
|
|
230
|
+
progress.remote_state = value.status.state;
|
|
231
|
+
} else {
|
|
232
|
+
a2aIdentity(value.artifact?.artifactId);
|
|
233
|
+
validateParts(value.artifact.parts);
|
|
234
|
+
if (!progress.artifact_ids.includes(value.artifact.artifactId))
|
|
235
|
+
progress.artifact_ids.push(value.artifact.artifactId);
|
|
236
|
+
if (progress.artifact_ids.length > 64)
|
|
237
|
+
throw new ServiceError('a2a_identity', 'Unbounded stream artifacts');
|
|
238
|
+
}
|
|
239
|
+
if (this.cp.state !== 'terminal') this.update({ progress });
|
|
240
|
+
}
|
|
241
|
+
async cancel(): Promise<void> {
|
|
242
|
+
if (this.cp.state === 'terminal') return;
|
|
243
|
+
this.update({ cancel_requested: true, cancel_state: 'intent' });
|
|
244
|
+
if (!this.cp.task_id) throw Error('Unknown remote acceptance cannot be canceled safely');
|
|
245
|
+
await this.inspect();
|
|
246
|
+
if (this.view().state === 'terminal') return;
|
|
247
|
+
if (!this.peer.cancel) {
|
|
248
|
+
this.update({ cancel_state: 'unsupported' });
|
|
249
|
+
if (this.view().state !== 'terminal')
|
|
250
|
+
throw Error('Peer cancellation is unsupported; remote stop remains unconfirmed');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.update({ cancel_state: 'dispatching' });
|
|
254
|
+
const task = await this.rpc.rpc('CancelTask', { id: this.cp.task_id });
|
|
255
|
+
this.accept(task);
|
|
256
|
+
this.update({ cancel_state: 'acknowledged' });
|
|
257
|
+
if (this.view().state !== 'terminal')
|
|
258
|
+
throw Error('Cancellation acknowledgement does not confirm remote stop');
|
|
259
|
+
}
|
|
260
|
+
static prepare(
|
|
261
|
+
worker: string,
|
|
262
|
+
attempt: string,
|
|
263
|
+
peer: { id: string; tenant: string; digest: string },
|
|
264
|
+
text: string,
|
|
265
|
+
data: A2AObject,
|
|
266
|
+
disclosure: A2ACheckpoint['disclosure'],
|
|
267
|
+
): A2ACheckpoint {
|
|
268
|
+
return {
|
|
269
|
+
schema_version: 1,
|
|
270
|
+
dialect: 'a2a',
|
|
271
|
+
worker_id: worker,
|
|
272
|
+
attempt_id: attempt,
|
|
273
|
+
peer_id: peer.id,
|
|
274
|
+
peer_digest: peer.digest,
|
|
275
|
+
tenant: peer.tenant,
|
|
276
|
+
message_id: randomUUID(),
|
|
277
|
+
task_id: null,
|
|
278
|
+
context_id: null,
|
|
279
|
+
state: 'prepared',
|
|
280
|
+
prompt_digest: digest({ text, data }),
|
|
281
|
+
cancel_requested: false,
|
|
282
|
+
result: null,
|
|
283
|
+
disclosure,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
openSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import {
|
|
14
|
+
canonical,
|
|
15
|
+
digest,
|
|
16
|
+
integer,
|
|
17
|
+
object,
|
|
18
|
+
ServiceError,
|
|
19
|
+
string,
|
|
20
|
+
textDigest,
|
|
21
|
+
type Controller,
|
|
22
|
+
} from './protocol.js';
|
|
23
|
+
import { providerRoute, type ProviderRoute } from './provider-config.js';
|
|
24
|
+
import type { Store } from './store.js';
|
|
25
|
+
import { DIAGNOSTIC_PROFILE } from './worker-boundary.js';
|
|
26
|
+
|
|
27
|
+
export interface AgentProfile {
|
|
28
|
+
schema_version: 1;
|
|
29
|
+
id: string;
|
|
30
|
+
revision: number;
|
|
31
|
+
tools: readonly string[];
|
|
32
|
+
max_depth: number;
|
|
33
|
+
role?: 'explorer' | 'reviewer' | 'builder' | 'tester' | 'integrator';
|
|
34
|
+
description?: string;
|
|
35
|
+
prompt?: string;
|
|
36
|
+
max_requests?: number;
|
|
37
|
+
provider?: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
source_digest?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface AgentGrant {
|
|
42
|
+
schema_version: 1;
|
|
43
|
+
id: string;
|
|
44
|
+
owner_session: string;
|
|
45
|
+
profile: AgentProfile;
|
|
46
|
+
profile_digest: string;
|
|
47
|
+
route: ProviderRoute;
|
|
48
|
+
output_cap: boolean;
|
|
49
|
+
facts: { id: string; kind: string; text: string }[];
|
|
50
|
+
}
|
|
51
|
+
const ROLE_TOOLS = {
|
|
52
|
+
explorer: ['salaam_probe', 'salaam_repository'],
|
|
53
|
+
reviewer: ['salaam_probe', 'salaam_repository', 'salaam_findings'],
|
|
54
|
+
builder: ['salaam_probe', 'salaam_repository', 'salaam_files', 'salaam_command'],
|
|
55
|
+
tester: ['salaam_probe', 'salaam_repository', 'salaam_command', 'salaam_findings'],
|
|
56
|
+
integrator: ['salaam_probe', 'salaam_repository', 'salaam_findings'],
|
|
57
|
+
} as const;
|
|
58
|
+
export function profileTools(role: keyof typeof ROLE_TOOLS): string[] {
|
|
59
|
+
return [...ROLE_TOOLS[role]].sort();
|
|
60
|
+
}
|
|
61
|
+
export const BUILTIN_PROFILES: AgentProfile[] = [
|
|
62
|
+
DIAGNOSTIC_PROFILE,
|
|
63
|
+
...(['explorer', 'reviewer', 'builder', 'tester', 'integrator'] as const).map((role) => ({
|
|
64
|
+
schema_version: 1 as const,
|
|
65
|
+
id: 'salaam/' + role,
|
|
66
|
+
revision: 1,
|
|
67
|
+
role,
|
|
68
|
+
tools: profileTools(role),
|
|
69
|
+
max_depth: 1,
|
|
70
|
+
max_requests: 8,
|
|
71
|
+
description:
|
|
72
|
+
role === 'explorer'
|
|
73
|
+
? 'Bounded evidence from an immutable repository snapshot'
|
|
74
|
+
: role === 'reviewer'
|
|
75
|
+
? 'Independent findings over an immutable repository diff'
|
|
76
|
+
: role === 'builder'
|
|
77
|
+
? 'Implement a proposal in a private owned workspace'
|
|
78
|
+
: role === 'tester'
|
|
79
|
+
? 'Run bounded checks in an isolated disposable copy'
|
|
80
|
+
: 'Review integration evidence without applying changes',
|
|
81
|
+
prompt:
|
|
82
|
+
role === 'explorer'
|
|
83
|
+
? 'Investigate only the supplied repository snapshot. Cite actual file paths, line numbers and hashes. Do not claim edits or execution.'
|
|
84
|
+
: role === 'builder'
|
|
85
|
+
? 'Implement only the assigned change in your private owned workspace using salaam_files. Run bounded checks through salaam_command. Command edits are disposable. Do not claim the root workspace was changed or verified; return a proposal for explicit integration.'
|
|
86
|
+
: role === 'tester'
|
|
87
|
+
? 'Inspect the admitted snapshot and run bounded checks using salaam_command. You cannot edit persistent files. Report actual outcomes; a model answer is not a root verification receipt.'
|
|
88
|
+
: 'Review only the supplied immutable diff and repository snapshot. Report concrete findings with file/line evidence through salaam_findings. A finding is analysis, not a verification receipt.',
|
|
89
|
+
})),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** JSON front matter is a deliberately bounded subset, not an executable config format. */
|
|
93
|
+
export function parseAgentProfile(name: string, source: string): AgentProfile {
|
|
94
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name) || Buffer.byteLength(source) > 49152)
|
|
95
|
+
throw new ServiceError('invalid_profile', 'Invalid profile name or size');
|
|
96
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(source);
|
|
97
|
+
if (!match) throw new ServiceError('invalid_profile', 'Use JSON front matter between --- lines');
|
|
98
|
+
let metadata: Record<string, unknown>;
|
|
99
|
+
try {
|
|
100
|
+
metadata = object(JSON.parse(match[1]!));
|
|
101
|
+
} catch {
|
|
102
|
+
throw new ServiceError('invalid_profile', 'Invalid JSON profile metadata');
|
|
103
|
+
}
|
|
104
|
+
const allowed = [
|
|
105
|
+
'description',
|
|
106
|
+
'revision',
|
|
107
|
+
'role',
|
|
108
|
+
'tools',
|
|
109
|
+
'provider',
|
|
110
|
+
'model',
|
|
111
|
+
'max_requests',
|
|
112
|
+
'max_depth',
|
|
113
|
+
];
|
|
114
|
+
if (Object.keys(metadata).some((key) => !allowed.includes(key)))
|
|
115
|
+
throw new ServiceError(
|
|
116
|
+
'invalid_profile',
|
|
117
|
+
'Profiles cannot define executables, credentials, plugins, environment or additional fields',
|
|
118
|
+
);
|
|
119
|
+
const role = string(metadata.role, 'role', 32) as keyof typeof ROLE_TOOLS;
|
|
120
|
+
if (!Object.hasOwn(ROLE_TOOLS, role))
|
|
121
|
+
throw new ServiceError('invalid_profile', 'Unsupported specialist role');
|
|
122
|
+
const tools =
|
|
123
|
+
metadata.tools === undefined
|
|
124
|
+
? profileTools(role)
|
|
125
|
+
: [
|
|
126
|
+
...new Set(
|
|
127
|
+
(Array.isArray(metadata.tools) ? metadata.tools : []).map((x) =>
|
|
128
|
+
string(x, 'tool', 128),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
].sort();
|
|
132
|
+
if (!tools.length || tools.some((x) => !profileTools(role).includes(x)))
|
|
133
|
+
throw new ServiceError('invalid_profile', 'Tools exceed the specialist role');
|
|
134
|
+
const revision = integer(metadata.revision ?? 1, 'revision');
|
|
135
|
+
const maxRequests = integer(metadata.max_requests ?? 8, 'max_requests');
|
|
136
|
+
if (revision < 1 || maxRequests < 1 || maxRequests > 8 || (metadata.max_depth ?? 1) !== 1)
|
|
137
|
+
throw new ServiceError(
|
|
138
|
+
'invalid_profile',
|
|
139
|
+
'Invalid revision, request limit or delegation depth',
|
|
140
|
+
);
|
|
141
|
+
if ((metadata.provider === undefined) !== (metadata.model === undefined))
|
|
142
|
+
throw new ServiceError('invalid_profile', 'Select both provider and model');
|
|
143
|
+
const prompt = string(match[2]!.trim(), 'prompt', 32768);
|
|
144
|
+
return {
|
|
145
|
+
schema_version: 1,
|
|
146
|
+
id: 'project/' + name,
|
|
147
|
+
revision,
|
|
148
|
+
role,
|
|
149
|
+
tools,
|
|
150
|
+
max_depth: 1,
|
|
151
|
+
description: string(metadata.description ?? name, 'description', 512),
|
|
152
|
+
prompt,
|
|
153
|
+
max_requests: maxRequests,
|
|
154
|
+
source_digest: textDigest(source),
|
|
155
|
+
...(metadata.provider
|
|
156
|
+
? {
|
|
157
|
+
provider: string(metadata.provider, 'provider', 128),
|
|
158
|
+
model: string(metadata.model, 'model', 256),
|
|
159
|
+
}
|
|
160
|
+
: {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class AgentProfiles {
|
|
165
|
+
assertExecution?: () => void;
|
|
166
|
+
validateFrame?: (session: string) => void;
|
|
167
|
+
constructor(
|
|
168
|
+
readonly store: Store,
|
|
169
|
+
private route: ProviderRoute,
|
|
170
|
+
private prepareRoute: (
|
|
171
|
+
route: ProviderRoute,
|
|
172
|
+
session: string,
|
|
173
|
+
) => Promise<{ route: ProviderRoute; output_cap: boolean }>,
|
|
174
|
+
) {}
|
|
175
|
+
list(session: string): AgentProfile[] {
|
|
176
|
+
const root = this.store.session(session).cwd;
|
|
177
|
+
const control = join(root, '.salaam'),
|
|
178
|
+
directory = join(control, 'agents');
|
|
179
|
+
for (const path of [control, directory]) {
|
|
180
|
+
if (!existsSync(path)) return structuredClone(BUILTIN_PROFILES);
|
|
181
|
+
const stat = lstatSync(path);
|
|
182
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
183
|
+
throw new ServiceError('invalid_profile', 'Profile directories must not be symlinks');
|
|
184
|
+
}
|
|
185
|
+
const names = readdirSync(directory)
|
|
186
|
+
.filter((name) => name.endsWith('.md'))
|
|
187
|
+
.sort();
|
|
188
|
+
if (names.length > 32) throw new ServiceError('invalid_profile', 'At most 32 project profiles');
|
|
189
|
+
const profiles = names.map((file) => {
|
|
190
|
+
const path = join(directory, file),
|
|
191
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
192
|
+
try {
|
|
193
|
+
const stat = fstatSync(fd);
|
|
194
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 49152)
|
|
195
|
+
throw new ServiceError('invalid_profile', 'Profile must be a bounded regular file');
|
|
196
|
+
return parseAgentProfile(file.slice(0, -3), readFileSync(fd, 'utf8'));
|
|
197
|
+
} finally {
|
|
198
|
+
closeSync(fd);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return [...structuredClone(BUILTIN_PROFILES), ...profiles];
|
|
202
|
+
}
|
|
203
|
+
inspect(session: string, id: string): AgentProfile {
|
|
204
|
+
this.store.session(session);
|
|
205
|
+
const builtin = BUILTIN_PROFILES.find((profile) => profile.id === id);
|
|
206
|
+
if (builtin) return structuredClone(builtin);
|
|
207
|
+
const profile = this.list(session).find((x) => x.id === id);
|
|
208
|
+
if (!profile) throw new ServiceError('not_found', 'Agent profile not found', 404);
|
|
209
|
+
return profile;
|
|
210
|
+
}
|
|
211
|
+
async admit(
|
|
212
|
+
session: string,
|
|
213
|
+
request: string,
|
|
214
|
+
profileId: string,
|
|
215
|
+
expectedDigest: string,
|
|
216
|
+
factIds: string[],
|
|
217
|
+
controller: Controller,
|
|
218
|
+
): Promise<AgentGrant> {
|
|
219
|
+
const input = { kind: 'agent-admit', session, profileId, expectedDigest, factIds };
|
|
220
|
+
const saved = this.store.cachedRequest<AgentGrant>('local-operator', request, input);
|
|
221
|
+
if (saved) return saved;
|
|
222
|
+
this.store.authorizeControl(session, controller);
|
|
223
|
+
const profile = this.inspect(session, profileId);
|
|
224
|
+
if (digest(profile) !== expectedDigest)
|
|
225
|
+
throw new ServiceError('profile_changed', 'Review the current profile digest');
|
|
226
|
+
const writing = profile.tools.some((tool) => ['salaam_files', 'salaam_command'].includes(tool));
|
|
227
|
+
if (writing) {
|
|
228
|
+
if (!this.assertExecution)
|
|
229
|
+
throw new ServiceError(
|
|
230
|
+
'profile_unavailable',
|
|
231
|
+
'Writing roles require the parallel execution boundary',
|
|
232
|
+
);
|
|
233
|
+
this.assertExecution();
|
|
234
|
+
}
|
|
235
|
+
const prepared = await this.prepareRoute(
|
|
236
|
+
profile.provider
|
|
237
|
+
? providerRoute(profile.provider, profile.model, Math.min(2048, this.route.max_tokens))
|
|
238
|
+
: this.route,
|
|
239
|
+
session,
|
|
240
|
+
);
|
|
241
|
+
return this.store.request('local-operator', request, input, () => {
|
|
242
|
+
this.store.authorizeControl(session, controller);
|
|
243
|
+
if (writing) this.assertExecution!();
|
|
244
|
+
if (digest(this.inspect(session, profileId)) !== expectedDigest)
|
|
245
|
+
throw new ServiceError('profile_changed', 'Profile changed during admission');
|
|
246
|
+
if (
|
|
247
|
+
profile.role &&
|
|
248
|
+
!this.store.db.prepare('SELECT id FROM workspaces WHERE session_id=?').get(session)
|
|
249
|
+
)
|
|
250
|
+
throw new ServiceError(
|
|
251
|
+
'workspace_unavailable',
|
|
252
|
+
'Specialists need an admitted coding workspace',
|
|
253
|
+
);
|
|
254
|
+
if (factIds.length > 16 || new Set(factIds).size !== factIds.length)
|
|
255
|
+
throw new ServiceError('invalid_profile', 'Select at most 16 distinct facts');
|
|
256
|
+
const facts = factIds.map((id) => {
|
|
257
|
+
const fact = this.store.db
|
|
258
|
+
.prepare(
|
|
259
|
+
'SELECT id,kind,text FROM task_facts WHERE id=? AND session_id=? AND id NOT IN (SELECT supersedes FROM task_facts WHERE supersedes IS NOT NULL)',
|
|
260
|
+
)
|
|
261
|
+
.get(id, session);
|
|
262
|
+
if (!fact) throw new ServiceError('not_found', 'Selected fact is unavailable', 404);
|
|
263
|
+
return { id: String(fact.id), kind: String(fact.kind), text: String(fact.text) };
|
|
264
|
+
});
|
|
265
|
+
const grant: AgentGrant = {
|
|
266
|
+
schema_version: 1,
|
|
267
|
+
id: randomUUID(),
|
|
268
|
+
owner_session: session,
|
|
269
|
+
profile,
|
|
270
|
+
profile_digest: expectedDigest,
|
|
271
|
+
route: prepared.route,
|
|
272
|
+
output_cap: prepared.output_cap,
|
|
273
|
+
facts,
|
|
274
|
+
};
|
|
275
|
+
const priorProfile = this.store.db
|
|
276
|
+
.prepare('SELECT digest FROM worker_profiles WHERE id=? AND revision=?')
|
|
277
|
+
.get(profile.id, profile.revision);
|
|
278
|
+
if (priorProfile && priorProfile.digest !== expectedDigest)
|
|
279
|
+
throw new ServiceError(
|
|
280
|
+
'profile_revision_conflict',
|
|
281
|
+
'Changed profile contents require a new revision',
|
|
282
|
+
);
|
|
283
|
+
this.store.db
|
|
284
|
+
.prepare(
|
|
285
|
+
'INSERT INTO agent_grants(id,owner_session,profile_id,document,digest,state) VALUES (?,?,?,?,?,?)',
|
|
286
|
+
)
|
|
287
|
+
.run(grant.id, session, profileId, canonical(grant), digest(grant), 'active');
|
|
288
|
+
this.store.db
|
|
289
|
+
.prepare(
|
|
290
|
+
'INSERT OR IGNORE INTO worker_profiles(id,revision,digest,document) VALUES (?,?,?,?)',
|
|
291
|
+
)
|
|
292
|
+
.run(profile.id, profile.revision, expectedDigest, canonical(profile));
|
|
293
|
+
this.store.event(session, 'agent.admitted', {
|
|
294
|
+
grant_id: grant.id,
|
|
295
|
+
profile_id: profileId,
|
|
296
|
+
profile_digest: expectedDigest,
|
|
297
|
+
route: prepared.route,
|
|
298
|
+
});
|
|
299
|
+
this.validateFrame?.(session);
|
|
300
|
+
return grant;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
resolve(session: string, id: string): AgentGrant {
|
|
304
|
+
const row = this.store.db
|
|
305
|
+
.prepare(
|
|
306
|
+
"SELECT document,digest FROM agent_grants WHERE owner_session=? AND profile_id=? AND state='active' ORDER BY rowid DESC LIMIT 1",
|
|
307
|
+
)
|
|
308
|
+
.get(session, id);
|
|
309
|
+
if (!row)
|
|
310
|
+
throw new ServiceError('authority_denied', 'Operator must admit this agent profile first');
|
|
311
|
+
const grant = JSON.parse(String(row.document)) as AgentGrant;
|
|
312
|
+
if (digest(grant) !== row.digest || digest(this.inspect(session, id)) !== grant.profile_digest)
|
|
313
|
+
throw new ServiceError('profile_changed', 'Profile changed; renew its admission');
|
|
314
|
+
return grant;
|
|
315
|
+
}
|
|
316
|
+
validate(grant: AgentGrant): void {
|
|
317
|
+
const row = this.store.db
|
|
318
|
+
.prepare('SELECT document,digest,state FROM agent_grants WHERE id=? AND owner_session=?')
|
|
319
|
+
.get(grant.id, grant.owner_session);
|
|
320
|
+
if (
|
|
321
|
+
!row ||
|
|
322
|
+
row.state !== 'active' ||
|
|
323
|
+
row.digest !== digest(grant) ||
|
|
324
|
+
row.document !== canonical(grant) ||
|
|
325
|
+
digest(this.inspect(grant.owner_session, grant.profile.id)) !== grant.profile_digest
|
|
326
|
+
)
|
|
327
|
+
throw new ServiceError(
|
|
328
|
+
'worker_profile_revoked',
|
|
329
|
+
'Specialist admission was revoked or changed',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
revoke(session: string, grantId: string, request: string, controller: Controller): unknown {
|
|
333
|
+
return this.store.request(
|
|
334
|
+
'local-operator',
|
|
335
|
+
request,
|
|
336
|
+
{ kind: 'agent-revoke', session, grantId },
|
|
337
|
+
() => {
|
|
338
|
+
this.store.authorizeControl(session, controller);
|
|
339
|
+
const result = this.store.db
|
|
340
|
+
.prepare("UPDATE agent_grants SET state='revoked' WHERE id=? AND owner_session=?")
|
|
341
|
+
.run(grantId, session);
|
|
342
|
+
if (!result.changes) throw new ServiceError('not_found', 'Agent admission not found', 404);
|
|
343
|
+
this.store.event(session, 'agent.revoked', { grant_id: grantId });
|
|
344
|
+
return { grant_id: grantId, state: 'revoked' };
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
snapshot(session: string) {
|
|
349
|
+
return this.store.db
|
|
350
|
+
.prepare(
|
|
351
|
+
'SELECT document,state FROM agent_grants WHERE owner_session=? ORDER BY rowid DESC LIMIT 64',
|
|
352
|
+
)
|
|
353
|
+
.all(session)
|
|
354
|
+
.reverse()
|
|
355
|
+
.map((row) => {
|
|
356
|
+
const grant = JSON.parse(String(row.document)) as AgentGrant;
|
|
357
|
+
let state = String(row.state);
|
|
358
|
+
if (state === 'active') {
|
|
359
|
+
try {
|
|
360
|
+
this.validate(grant);
|
|
361
|
+
} catch {
|
|
362
|
+
state = 'stale';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
grant_id: grant.id,
|
|
367
|
+
profile_id: grant.profile.id,
|
|
368
|
+
revision: grant.profile.revision,
|
|
369
|
+
profile_digest: grant.profile_digest,
|
|
370
|
+
tools: grant.profile.tools,
|
|
371
|
+
role: grant.profile.role,
|
|
372
|
+
route: grant.route,
|
|
373
|
+
output_cap: grant.output_cap,
|
|
374
|
+
selected_facts: grant.facts.map((f) => f.id),
|
|
375
|
+
state,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|