@flowdular/sandbox 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/internal/coding-agent/src/index.ts +6 -1
- package/internal/coding-agent/src/roles/defaults.ts +5 -1
- package/internal/coding-agent/src/roles/skills.ts +42 -6
- package/internal/coding-agent/src/workspace.ts +4 -0
- package/package.json +2 -2
- package/src/App.tsrx +46 -0
- package/src/client/ChatPane.tsrx +73 -12
- package/src/client/PendingQuestionsCard.tsrx +193 -0
- package/src/client/api.ts +55 -23
- package/src/client/locales/en.json +11 -0
- package/src/client/locales/pl.json +11 -0
- package/src/client/state.ts +21 -0
- package/src/server/preview-runtime.ts +58 -6
- package/src/server/preview-worker-manager.ts +1 -0
- package/src/server/preview-worker.ts +6 -3
- package/src/server/questions.ts +356 -0
- package/src/server/reference.ts +27 -1
- package/src/server/routes.ts +70 -0
- package/src/server/sessions.ts +7 -0
- package/src/server/turns.ts +44 -2
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/* The active-questions protocol. A specialist that needs a decision from the
|
|
2
|
+
operator closes its reply with one fenced block tagged `questions`; this
|
|
3
|
+
module is the only place that turns that text into data, so a malformed block
|
|
4
|
+
is a turn warning instead of raw JSON the operator has to read. Nothing here
|
|
5
|
+
touches the file system or the network: the dashboard imports the same
|
|
6
|
+
parser, so the transcript and the server never disagree about a block. */
|
|
7
|
+
|
|
8
|
+
export interface PendingQuestion {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly question: string;
|
|
11
|
+
readonly options: readonly string[];
|
|
12
|
+
/* One of `options`. Absent when the specialist recommends nothing. */
|
|
13
|
+
readonly recommended?: string;
|
|
14
|
+
readonly allowFreeText: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* What the session carries between the turn that asked and the answers that
|
|
18
|
+
start the next one: the questions, who asked them, and where. */
|
|
19
|
+
export interface PendingQuestions {
|
|
20
|
+
/* The transcript sequence of the agent message the block came from. */
|
|
21
|
+
readonly sequence: number;
|
|
22
|
+
readonly role: string;
|
|
23
|
+
readonly module?: string;
|
|
24
|
+
readonly askedAt: number;
|
|
25
|
+
readonly questions: readonly PendingQuestion[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const MAX_QUESTIONS = 12;
|
|
29
|
+
export const MAX_OPTIONS = 8;
|
|
30
|
+
export const MAX_QUESTION_LENGTH = 400;
|
|
31
|
+
export const MAX_OPTION_LENGTH = 120;
|
|
32
|
+
export const MAX_ANSWER_LENGTH = 400;
|
|
33
|
+
/* The block is a small decision list, not a document: a larger one is refused
|
|
34
|
+
before JSON.parse allocates it. */
|
|
35
|
+
export const MAX_BLOCK_LENGTH = 8_000;
|
|
36
|
+
|
|
37
|
+
const QUESTION_ID = /^Q-[0-9]+$/;
|
|
38
|
+
/* A question, an option and an answer each become one line of the decisions the
|
|
39
|
+
answered turn reads back. A line break or a control character inside one would
|
|
40
|
+
let the text that wrote it forge further decision lines, so the value is
|
|
41
|
+
refused whole rather than repaired. */
|
|
42
|
+
const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
|
|
43
|
+
const FENCE = /^[ \t]*```[ \t]*([A-Za-z0-9_-]*)[ \t]*$/;
|
|
44
|
+
/* Every role closes with a handoff line, so it may follow the block. */
|
|
45
|
+
const HANDOFF_LINE = /^[ \t]*(?:[`*_]*)handoff[ \t]*:/i;
|
|
46
|
+
|
|
47
|
+
export interface QuestionsBlock {
|
|
48
|
+
readonly raw: string;
|
|
49
|
+
/* The reply without the block, which is what the transcript speaks. */
|
|
50
|
+
readonly remainder: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type QuestionsReading =
|
|
54
|
+
| { readonly kind: 'none' }
|
|
55
|
+
| {
|
|
56
|
+
readonly kind: 'valid';
|
|
57
|
+
readonly questions: readonly PendingQuestion[];
|
|
58
|
+
readonly remainder: string;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
readonly kind: 'invalid';
|
|
62
|
+
readonly reason: string;
|
|
63
|
+
readonly remainder: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
interface FencedBlock {
|
|
67
|
+
readonly tag: string;
|
|
68
|
+
readonly open: number;
|
|
69
|
+
readonly close: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* One pass over the lines, pairing fences in order: an opening fence carries
|
|
73
|
+
the info string, the next fence closes it. An unterminated fence is not a
|
|
74
|
+
block, so half-streamed output never parses as one. */
|
|
75
|
+
function fencedBlocks(lines: readonly string[]): readonly FencedBlock[] {
|
|
76
|
+
const blocks: FencedBlock[] = [];
|
|
77
|
+
let open = -1;
|
|
78
|
+
let tag = '';
|
|
79
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
80
|
+
const match = FENCE.exec(lines[index]!);
|
|
81
|
+
if (!match) continue;
|
|
82
|
+
if (open < 0) {
|
|
83
|
+
open = index;
|
|
84
|
+
tag = match[1] ?? '';
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
blocks.push({ tag, open, close: index });
|
|
88
|
+
open = -1;
|
|
89
|
+
tag = '';
|
|
90
|
+
}
|
|
91
|
+
return blocks;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The block the protocol recognises: tagged `questions`, the last thing in the
|
|
95
|
+
reply apart from the mandatory handoff line. A block in the middle of an
|
|
96
|
+
explanation is prose about the protocol, not an instance of it. */
|
|
97
|
+
export function findQuestionsBlock(
|
|
98
|
+
text: string,
|
|
99
|
+
): QuestionsBlock | { readonly reason: string } | null {
|
|
100
|
+
if (!text.includes('```')) return null;
|
|
101
|
+
const lines = text.split('\n');
|
|
102
|
+
const tagged = fencedBlocks(lines).filter(
|
|
103
|
+
(block) => block.tag.toLowerCase() === 'questions',
|
|
104
|
+
);
|
|
105
|
+
if (tagged.length === 0) return null;
|
|
106
|
+
if (tagged.length > 1) {
|
|
107
|
+
return { reason: 'A reply carries at most one questions block.' };
|
|
108
|
+
}
|
|
109
|
+
const block = tagged[0]!;
|
|
110
|
+
for (let index = block.close + 1; index < lines.length; index += 1) {
|
|
111
|
+
const line = lines[index]!;
|
|
112
|
+
if (!line.trim() || HANDOFF_LINE.test(line)) continue;
|
|
113
|
+
return {
|
|
114
|
+
reason: 'The questions block must be the last thing in the reply.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const before = lines.slice(0, block.open);
|
|
118
|
+
const after = lines.slice(block.close + 1);
|
|
119
|
+
return {
|
|
120
|
+
raw: lines.slice(block.open + 1, block.close).join('\n'),
|
|
121
|
+
remainder: [...before, ...after].join('\n').trim(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function invalid(reason: string): {
|
|
126
|
+
readonly ok: false;
|
|
127
|
+
readonly reason: string;
|
|
128
|
+
} {
|
|
129
|
+
return { ok: false, reason };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type QuestionsParse =
|
|
133
|
+
| { readonly ok: true; readonly questions: readonly PendingQuestion[] }
|
|
134
|
+
| { readonly ok: false; readonly reason: string };
|
|
135
|
+
|
|
136
|
+
/* Every bound is checked before the value is kept, so a session record can only
|
|
137
|
+
ever hold a list the answers form and the answers route both accept. */
|
|
138
|
+
export function parseQuestionsBlock(raw: string): QuestionsParse {
|
|
139
|
+
if (raw.length > MAX_BLOCK_LENGTH) {
|
|
140
|
+
return invalid(
|
|
141
|
+
`A questions block is limited to ${MAX_BLOCK_LENGTH} characters.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
let value: unknown;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(raw) as unknown;
|
|
147
|
+
} catch {
|
|
148
|
+
return invalid('The questions block is not valid JSON.');
|
|
149
|
+
}
|
|
150
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
151
|
+
return invalid('The questions block must be a JSON object.');
|
|
152
|
+
}
|
|
153
|
+
const list = (value as { questions?: unknown }).questions;
|
|
154
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
155
|
+
return invalid('The questions block needs a non-empty questions array.');
|
|
156
|
+
}
|
|
157
|
+
if (list.length > MAX_QUESTIONS) {
|
|
158
|
+
return invalid(
|
|
159
|
+
`A questions block asks at most ${MAX_QUESTIONS} questions.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const questions: PendingQuestion[] = [];
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
for (const entry of list as readonly unknown[]) {
|
|
165
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
166
|
+
return invalid('Every question must be a JSON object.');
|
|
167
|
+
}
|
|
168
|
+
const item = entry as Record<string, unknown>;
|
|
169
|
+
const id = item.id;
|
|
170
|
+
if (typeof id !== 'string' || !QUESTION_ID.test(id)) {
|
|
171
|
+
return invalid('Every question id must look like Q-1.');
|
|
172
|
+
}
|
|
173
|
+
if (seen.has(id)) return invalid(`Question id ${id} is used twice.`);
|
|
174
|
+
seen.add(id);
|
|
175
|
+
const question = item.question;
|
|
176
|
+
if (
|
|
177
|
+
typeof question !== 'string' ||
|
|
178
|
+
question.trim().length === 0 ||
|
|
179
|
+
question.length > MAX_QUESTION_LENGTH
|
|
180
|
+
) {
|
|
181
|
+
return invalid(
|
|
182
|
+
`${id} must ask a question of 1 to ${MAX_QUESTION_LENGTH} characters.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (CONTROL_CHARACTER.test(question.trim())) {
|
|
186
|
+
return invalid(
|
|
187
|
+
`${id} may not contain a line break or a control character.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const rawOptions = item.options ?? [];
|
|
191
|
+
if (!Array.isArray(rawOptions) || rawOptions.length > MAX_OPTIONS) {
|
|
192
|
+
return invalid(`${id} may offer at most ${MAX_OPTIONS} options.`);
|
|
193
|
+
}
|
|
194
|
+
const options: string[] = [];
|
|
195
|
+
for (const option of rawOptions as readonly unknown[]) {
|
|
196
|
+
if (
|
|
197
|
+
typeof option !== 'string' ||
|
|
198
|
+
option.trim().length === 0 ||
|
|
199
|
+
option.length > MAX_OPTION_LENGTH
|
|
200
|
+
) {
|
|
201
|
+
return invalid(
|
|
202
|
+
`Every option of ${id} must be 1 to ${MAX_OPTION_LENGTH} characters.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (CONTROL_CHARACTER.test(option.trim())) {
|
|
206
|
+
return invalid(
|
|
207
|
+
`No option of ${id} may contain a line break or a control character.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (options.includes(option.trim())) {
|
|
211
|
+
return invalid(`${id} repeats the option ${option.trim()}.`);
|
|
212
|
+
}
|
|
213
|
+
options.push(option.trim());
|
|
214
|
+
}
|
|
215
|
+
const allowFreeText = item.allowFreeText ?? false;
|
|
216
|
+
if (typeof allowFreeText !== 'boolean') {
|
|
217
|
+
return invalid(`allowFreeText of ${id} must be true or false.`);
|
|
218
|
+
}
|
|
219
|
+
if (options.length === 0 && !allowFreeText) {
|
|
220
|
+
return invalid(
|
|
221
|
+
`${id} offers no option and no free text, so it cannot be answered.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const recommended = item.recommended;
|
|
225
|
+
if (recommended !== undefined && recommended !== null) {
|
|
226
|
+
if (
|
|
227
|
+
typeof recommended !== 'string' ||
|
|
228
|
+
!options.includes(recommended.trim())
|
|
229
|
+
) {
|
|
230
|
+
return invalid(
|
|
231
|
+
`The recommendation of ${id} must be one of its options.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
questions.push({
|
|
236
|
+
id,
|
|
237
|
+
question: question.trim(),
|
|
238
|
+
options,
|
|
239
|
+
...(typeof recommended === 'string' &&
|
|
240
|
+
options.includes(recommended.trim())
|
|
241
|
+
? { recommended: recommended.trim() }
|
|
242
|
+
: {}),
|
|
243
|
+
allowFreeText,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return { ok: true, questions };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* The whole reading of one agent message: whether it asked, what it asked, and
|
|
250
|
+
what is left to show as speech. */
|
|
251
|
+
export function readQuestions(text: string): QuestionsReading {
|
|
252
|
+
const found = findQuestionsBlock(text);
|
|
253
|
+
if (!found) return { kind: 'none' };
|
|
254
|
+
if ('reason' in found) {
|
|
255
|
+
return { kind: 'invalid', reason: found.reason, remainder: text };
|
|
256
|
+
}
|
|
257
|
+
const parsed = parseQuestionsBlock(found.raw);
|
|
258
|
+
if (!parsed.ok) {
|
|
259
|
+
return {
|
|
260
|
+
kind: 'invalid',
|
|
261
|
+
reason: parsed.reason,
|
|
262
|
+
remainder: found.remainder,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
kind: 'valid',
|
|
267
|
+
questions: parsed.questions,
|
|
268
|
+
remainder: found.remainder,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface AnswerDecision {
|
|
273
|
+
readonly id: string;
|
|
274
|
+
readonly question: string;
|
|
275
|
+
readonly answer: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type AnswersParse =
|
|
279
|
+
| { readonly ok: true; readonly decisions: readonly AnswerDecision[] }
|
|
280
|
+
| { readonly ok: false; readonly reason: string };
|
|
281
|
+
|
|
282
|
+
/* The answers a request carries, checked against the questions this session
|
|
283
|
+
actually asked: every pending question is answered exactly once, and an
|
|
284
|
+
answer that is not free text must be one of the offered options. */
|
|
285
|
+
export function resolveAnswers(
|
|
286
|
+
pending: PendingQuestions,
|
|
287
|
+
value: unknown,
|
|
288
|
+
): AnswersParse {
|
|
289
|
+
if (!Array.isArray(value)) {
|
|
290
|
+
return invalid('answers must be an array.');
|
|
291
|
+
}
|
|
292
|
+
if (value.length !== pending.questions.length) {
|
|
293
|
+
return invalid(
|
|
294
|
+
`This session is waiting for ${pending.questions.length} answers.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const byId = new Map(pending.questions.map((entry) => [entry.id, entry]));
|
|
298
|
+
const decisions: AnswerDecision[] = [];
|
|
299
|
+
for (const entry of value as readonly unknown[]) {
|
|
300
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
301
|
+
return invalid('Every answer must be a JSON object.');
|
|
302
|
+
}
|
|
303
|
+
const item = entry as Record<string, unknown>;
|
|
304
|
+
const id = item.id;
|
|
305
|
+
if (typeof id !== 'string' || !byId.has(id)) {
|
|
306
|
+
return invalid('Every answer must name a pending question.');
|
|
307
|
+
}
|
|
308
|
+
const question = byId.get(id)!;
|
|
309
|
+
byId.delete(id);
|
|
310
|
+
/* A record written before the parser refused these still has to stay out
|
|
311
|
+
of the decisions text. */
|
|
312
|
+
if (CONTROL_CHARACTER.test(question.question)) {
|
|
313
|
+
return invalid(
|
|
314
|
+
`${id} carries a line break or a control character and cannot be answered.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const answer = item.answer;
|
|
318
|
+
if (
|
|
319
|
+
typeof answer !== 'string' ||
|
|
320
|
+
answer.trim().length === 0 ||
|
|
321
|
+
answer.length > MAX_ANSWER_LENGTH
|
|
322
|
+
) {
|
|
323
|
+
return invalid(
|
|
324
|
+
`The answer to ${id} must be 1 to ${MAX_ANSWER_LENGTH} characters.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const trimmed = answer.trim();
|
|
328
|
+
if (CONTROL_CHARACTER.test(trimmed)) {
|
|
329
|
+
return invalid(
|
|
330
|
+
`The answer to ${id} may not contain a line break or a control character.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
!question.allowFreeText &&
|
|
335
|
+
question.options.length > 0 &&
|
|
336
|
+
!question.options.includes(trimmed)
|
|
337
|
+
) {
|
|
338
|
+
return invalid(`The answer to ${id} must be one of its options.`);
|
|
339
|
+
}
|
|
340
|
+
decisions.push({ id, question: question.question, answer: trimmed });
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, decisions };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* The request text the answered turn starts from. The specialist reads its own
|
|
346
|
+
questions back with the decision beside each one, so it never has to guess
|
|
347
|
+
which answer belongs to which question. */
|
|
348
|
+
export function formatDecisions(decisions: readonly AnswerDecision[]): string {
|
|
349
|
+
return [
|
|
350
|
+
'Decisions:',
|
|
351
|
+
...decisions.map(
|
|
352
|
+
(decision) =>
|
|
353
|
+
`- ${decision.id}: ${decision.question} -> ${decision.answer}`,
|
|
354
|
+
),
|
|
355
|
+
].join('\n');
|
|
356
|
+
}
|
package/src/server/reference.ts
CHANGED
|
@@ -36,6 +36,9 @@ export async function referenceSource(
|
|
|
36
36
|
const REFERENCE_SOURCES: readonly {
|
|
37
37
|
readonly from: string;
|
|
38
38
|
readonly to: string;
|
|
39
|
+
/* A source whose absence is a defect rather than a shape the workspace may
|
|
40
|
+
legitimately have, so a failed copy is reported instead of swallowed. */
|
|
41
|
+
readonly required?: boolean;
|
|
39
42
|
}[] = [
|
|
40
43
|
{ from: 'packages/server/src', to: 'reference/packages/server/src' },
|
|
41
44
|
{
|
|
@@ -54,6 +57,14 @@ const REFERENCE_SOURCES: readonly {
|
|
|
54
57
|
from: 'packages/contracts/schemas',
|
|
55
58
|
to: 'reference/packages/contracts/schemas',
|
|
56
59
|
},
|
|
60
|
+
{
|
|
61
|
+
from: 'packages/kernel/src/data-class-registry.ts',
|
|
62
|
+
to: 'reference/packages/kernel/data-class-registry.ts',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
from: 'packages/storage/src/index.ts',
|
|
66
|
+
to: 'reference/packages/storage/index.ts',
|
|
67
|
+
},
|
|
57
68
|
{ from: 'packages/ui/src/index.ts', to: 'reference/packages/ui/index.ts' },
|
|
58
69
|
{
|
|
59
70
|
from: 'packages/ui/src/components',
|
|
@@ -89,6 +100,11 @@ const REFERENCE_SOURCES: readonly {
|
|
|
89
100
|
to: 'reference/auth-core/auth-service.ts',
|
|
90
101
|
},
|
|
91
102
|
{ from: 'AGENTS.md', to: 'reference/AGENTS.md' },
|
|
103
|
+
{
|
|
104
|
+
from: '.ai/platform-capabilities.md',
|
|
105
|
+
to: 'reference/platform-capabilities.md',
|
|
106
|
+
required: true,
|
|
107
|
+
},
|
|
92
108
|
{ from: 'docs/design-system.md', to: 'reference/design-system.md' },
|
|
93
109
|
{ from: 'docs/agent-contract.md', to: 'reference/agent-contract.md' },
|
|
94
110
|
{ from: '.ai/skills', to: 'reference/skills' },
|
|
@@ -109,10 +125,13 @@ not ejected.
|
|
|
109
125
|
- packages/server: defineEndpoint, HTTP helpers, and the endpoint identity contract.
|
|
110
126
|
- packages/client: the client contribution contract (createClientContribution, ModuleClientContext), shell slots, and shell state.
|
|
111
127
|
- packages/contracts: module manifest, spec, and blueprint schemas.
|
|
128
|
+
- packages/kernel/data-class-registry.ts: the data class declaration a module owning rows makes through context.dataClasses, with its sweep and export operations.
|
|
129
|
+
- packages/storage/index.ts: the object storage port a module writes files through.
|
|
112
130
|
- packages/ui: every shared primitive and the ui-* class list.
|
|
113
131
|
- example-module: a complete module, from ACL to client view, including src/platform.ts. Follow its shape.
|
|
114
132
|
- adapter-module: the same shape on the @flowdular/sdk/database provider contract, with an async repository, dialect-explicit migrations and a lease-owning runtime. Follow it when the module stores data.
|
|
115
133
|
- auth-core: the public surface of auth.core, including its scopes, the PlatformServerContext composition contract, and its service API.
|
|
134
|
+
- platform-capabilities.md: what a module can be built from and what the platform does not have yet. Read it before a specification promises anything.
|
|
116
135
|
- AGENTS.md and design-system.md: the workspace rules that gates enforce.
|
|
117
136
|
- agent-contract.md: detailed lookup reference, not required reading.
|
|
118
137
|
- skills: read only the Task skill named in your Session instruction. Other files are available for later tasks, not for preloading.
|
|
@@ -166,7 +185,14 @@ export async function materializeReference(
|
|
|
166
185
|
!relative(from, path)
|
|
167
186
|
.split('/')
|
|
168
187
|
.some((segment) => EXCLUDED.has(segment)),
|
|
169
|
-
}).catch(() =>
|
|
188
|
+
}).catch((error: unknown) => {
|
|
189
|
+
if (!source.required) return;
|
|
190
|
+
console.warn(
|
|
191
|
+
`Sandbox reference: ${source.from} was not copied into the session workspace (${
|
|
192
|
+
error instanceof Error ? error.message : String(error)
|
|
193
|
+
}). A skill that cites it will not find it.`,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
170
196
|
}
|
|
171
197
|
await materializeSdkReference(workspaceRoot, sessionWorkspace);
|
|
172
198
|
const skills = await listSkills(workspaceRoot);
|
package/src/server/routes.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
specPathOf,
|
|
79
79
|
type ModuleSpecReview,
|
|
80
80
|
} from './spec.ts';
|
|
81
|
+
import { formatDecisions, resolveAnswers } from './questions.ts';
|
|
81
82
|
import type { BrowserSession, SandboxRuntime } from './runtime.ts';
|
|
82
83
|
import { PlatformClient } from './platform-client.ts';
|
|
83
84
|
import { SandboxSetupError } from './workspace-root.ts';
|
|
@@ -131,6 +132,7 @@ const STATUS_BY_CODE: Readonly<Record<string, number>> = {
|
|
|
131
132
|
MODULE_ALREADY_IN_SESSION: 409,
|
|
132
133
|
SPEC_NOT_FOUND: 404,
|
|
133
134
|
SPEC_NOT_APPROVED: 409,
|
|
135
|
+
NO_PENDING_QUESTIONS: 409,
|
|
134
136
|
EJECT_SPEC_MISSING: 409,
|
|
135
137
|
EJECT_SPEC_NOT_APPROVED: 409,
|
|
136
138
|
EJECT_SPEC_VERSION_UNCHANGED: 409,
|
|
@@ -143,6 +145,9 @@ const STATUS_BY_CODE: Readonly<Record<string, number>> = {
|
|
|
143
145
|
const MAX_SPEC_TEXT = 200_000;
|
|
144
146
|
const MAX_SPEC_COMMENT = 4_000;
|
|
145
147
|
const MAX_JSON_BODY_BYTES = 256_000;
|
|
148
|
+
/* What an operator may add beside the decisions. The decision list is bounded
|
|
149
|
+
by the questions protocol, so the two together stay inside a turn message. */
|
|
150
|
+
const MAX_ANSWER_NOTE = 8_000;
|
|
146
151
|
|
|
147
152
|
export interface SandboxRouteOptions {
|
|
148
153
|
/* The port the launcher bound. A loopback request must name it in its Host
|
|
@@ -626,6 +631,14 @@ function assertNotDelivered(session: SandboxSession): void {
|
|
|
626
631
|
}
|
|
627
632
|
}
|
|
628
633
|
|
|
634
|
+
/* The turn one accepted answer set starts: the decisions the specialist reads
|
|
635
|
+
back, in the role and the module that asked for them. */
|
|
636
|
+
interface AnsweredTurn {
|
|
637
|
+
readonly message: string;
|
|
638
|
+
readonly role: string;
|
|
639
|
+
readonly module?: string;
|
|
640
|
+
}
|
|
641
|
+
|
|
629
642
|
/* A turn, or a chain of automatically continued turns, runs to completion on
|
|
630
643
|
the server whatever happens to the browser. Streams subscribe to it and can
|
|
631
644
|
leave at any time; stopping is an explicit action. The finished promise is
|
|
@@ -666,6 +679,7 @@ export function createSandboxRoutes(
|
|
|
666
679
|
sessionId: string,
|
|
667
680
|
input: {
|
|
668
681
|
message: string;
|
|
682
|
+
skillTask?: string;
|
|
669
683
|
freshContext?: boolean;
|
|
670
684
|
role?: string;
|
|
671
685
|
module?: string;
|
|
@@ -701,6 +715,7 @@ export function createSandboxRoutes(
|
|
|
701
715
|
});
|
|
702
716
|
let next: {
|
|
703
717
|
message: string;
|
|
718
|
+
skillTask?: string;
|
|
704
719
|
role?: string;
|
|
705
720
|
module?: string;
|
|
706
721
|
driver?: string;
|
|
@@ -1546,6 +1561,60 @@ export function createSandboxRoutes(
|
|
|
1546
1561
|
},
|
|
1547
1562
|
});
|
|
1548
1563
|
|
|
1564
|
+
/* Answering the questions the last turn asked is a turn of its own: the
|
|
1565
|
+
decisions lead the request text, the operator's own words follow them, and
|
|
1566
|
+
the specialist that asked takes the turn in the module it asked about. */
|
|
1567
|
+
const answerQuestions = new ServerRoute({
|
|
1568
|
+
path: '/sandbox/api/sessions/:id/answers',
|
|
1569
|
+
methods: ['POST'],
|
|
1570
|
+
handler: async (context) => {
|
|
1571
|
+
try {
|
|
1572
|
+
await authorize(runtime, context, options, { mutation: true });
|
|
1573
|
+
const sessionId = sessionIdParam(context);
|
|
1574
|
+
const value = await body(context.request);
|
|
1575
|
+
const note = optionalText(value, 'message', MAX_ANSWER_NOTE);
|
|
1576
|
+
let answered: AnsweredTurn | null = null;
|
|
1577
|
+
/* Reading the questions, resolving them and clearing them is one
|
|
1578
|
+
step under the session lock: two submissions arriving together
|
|
1579
|
+
must not both find the same decisions pending and start a turn
|
|
1580
|
+
from them. */
|
|
1581
|
+
await updateSession(runtime.workspaceRoot, sessionId, (current) => {
|
|
1582
|
+
assertNotArchived(current);
|
|
1583
|
+
assertNotDelivered(current);
|
|
1584
|
+
const pending = current.pendingQuestions;
|
|
1585
|
+
if (!pending || pending.questions.length === 0) {
|
|
1586
|
+
throw new SandboxSetupError(
|
|
1587
|
+
'NO_PENDING_QUESTIONS',
|
|
1588
|
+
'This session is not waiting for a decision.',
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
const resolved = resolveAnswers(pending, value.answers);
|
|
1592
|
+
if (!resolved.ok) {
|
|
1593
|
+
throw new SandboxSetupError('INVALID_INPUT', resolved.reason);
|
|
1594
|
+
}
|
|
1595
|
+
const decisions = formatDecisions(resolved.decisions);
|
|
1596
|
+
answered = {
|
|
1597
|
+
message: note ? `${decisions}\n\n${note}` : decisions,
|
|
1598
|
+
role: pending.role,
|
|
1599
|
+
...(pending.module ? { module: pending.module } : {}),
|
|
1600
|
+
};
|
|
1601
|
+
return { pendingQuestions: null };
|
|
1602
|
+
});
|
|
1603
|
+
const turn: AnsweredTurn = answered!;
|
|
1604
|
+
const channel = startTurn(
|
|
1605
|
+
sessionId,
|
|
1606
|
+
/* The decisions text is the specialist's own words read back, so
|
|
1607
|
+
only the operator's note may name a skill for this turn. */
|
|
1608
|
+
{ ...turn, skillTask: note ?? '' },
|
|
1609
|
+
actingPlatform(runtime, context),
|
|
1610
|
+
);
|
|
1611
|
+
return streamChannel(channel);
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
return failure(error);
|
|
1614
|
+
}
|
|
1615
|
+
},
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1549
1618
|
/* Follow a turn another browser, or an earlier page load, started. */
|
|
1550
1619
|
const followTurn = new ServerRoute({
|
|
1551
1620
|
path: '/sandbox/api/sessions/:id/turn/stream',
|
|
@@ -2232,6 +2301,7 @@ export function createSandboxRoutes(
|
|
|
2232
2301
|
restoreSandboxCheckpoint,
|
|
2233
2302
|
removeSandboxSession,
|
|
2234
2303
|
turn,
|
|
2304
|
+
answerQuestions,
|
|
2235
2305
|
followTurn,
|
|
2236
2306
|
stop,
|
|
2237
2307
|
settings,
|
package/src/server/sessions.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
16
16
|
import type { CodingAgentEvent } from '#coding-agent';
|
|
17
17
|
import { sandboxDirectory } from './config.ts';
|
|
18
|
+
import type { PendingQuestions } from './questions.ts';
|
|
18
19
|
import { materializeModuleGraph, materializeReference } from './reference.ts';
|
|
19
20
|
import { hashSpec } from './spec.ts';
|
|
20
21
|
import { forgetDiffs } from './turns.ts';
|
|
@@ -121,6 +122,10 @@ export interface SandboxSession {
|
|
|
121
122
|
/* Restore points, oldest first. Bounded; see checkpoints.ts for the cap and
|
|
122
123
|
the pruning that never drops the start. */
|
|
123
124
|
readonly checkpoints: readonly SessionCheckpoint[];
|
|
125
|
+
/* The decisions the last turn asked the operator for, or null when it asked
|
|
126
|
+
for none. Every turn rewrites it, so the form can only ever show what the
|
|
127
|
+
newest specialist is still waiting on. */
|
|
128
|
+
readonly pendingQuestions: PendingQuestions | null;
|
|
124
129
|
readonly state: SandboxSessionState;
|
|
125
130
|
readonly createdAt: number;
|
|
126
131
|
readonly updatedAt: number;
|
|
@@ -456,6 +461,7 @@ export async function createSession(
|
|
|
456
461
|
checkpoints: [
|
|
457
462
|
{ sequence: 0, at: now, label: 'the starting point', role: input.role },
|
|
458
463
|
],
|
|
464
|
+
pendingQuestions: null,
|
|
459
465
|
state: 'draft',
|
|
460
466
|
createdAt: now,
|
|
461
467
|
updatedAt: now,
|
|
@@ -551,6 +557,7 @@ export async function readSession(
|
|
|
551
557
|
chainDepth: record.chainDepth ?? 0,
|
|
552
558
|
attachments: record.attachments ?? [],
|
|
553
559
|
checkpoints: record.checkpoints ?? [],
|
|
560
|
+
pendingQuestions: record.pendingQuestions ?? null,
|
|
554
561
|
ejectedAt: record.ejectedAt ?? null,
|
|
555
562
|
archivedAt: record.archivedAt ?? null,
|
|
556
563
|
};
|
package/src/server/turns.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
type RoutingContext,
|
|
54
54
|
} from './planning.ts';
|
|
55
55
|
import type { PlatformClient } from './platform-client.ts';
|
|
56
|
+
import { readQuestions } from './questions.ts';
|
|
56
57
|
import { listSkills, writeAgentPointer } from './reference.ts';
|
|
57
58
|
import {
|
|
58
59
|
appendChatEntry,
|
|
@@ -97,6 +98,10 @@ export interface TurnInput {
|
|
|
97
98
|
readonly freshContext?: boolean;
|
|
98
99
|
readonly sessionId: string;
|
|
99
100
|
readonly message: string;
|
|
101
|
+
/* The operator's own words, when the request text also carries text a
|
|
102
|
+
specialist wrote. Only this is scanned for an explicit $skill, so a
|
|
103
|
+
question can never choose the skill of the turn that answers it. */
|
|
104
|
+
readonly skillTask?: string;
|
|
100
105
|
readonly role?: string;
|
|
101
106
|
/* The draft module directory this turn works in. Defaults to the module the
|
|
102
107
|
last handoff named, then to the primary module. */
|
|
@@ -646,7 +651,14 @@ export async function* runTurn(
|
|
|
646
651
|
sessionKind: active.kind === 'new' ? 'new-module' : 'edit-module',
|
|
647
652
|
blueprint: session.blueprint,
|
|
648
653
|
task: message,
|
|
654
|
+
...(input.skillTask === undefined
|
|
655
|
+
? {}
|
|
656
|
+
: { explicitSkillSource: input.skillTask }),
|
|
649
657
|
available: await listSkills(context.workspaceRoot),
|
|
658
|
+
/* The gate of the module this turn works in, read before the driver ran:
|
|
659
|
+
until the operator approved that exact text there is nothing to
|
|
660
|
+
implement from, so a new module starts as an interview. */
|
|
661
|
+
specApproved: gate.approved === true,
|
|
650
662
|
});
|
|
651
663
|
const reviewing = skill === 'auto-review';
|
|
652
664
|
if (reviewing) await prepareAutoReview(context.workspaceRoot, paths, active);
|
|
@@ -736,6 +748,9 @@ export async function* runTurn(
|
|
|
736
748
|
let nextResumeId = resumeId;
|
|
737
749
|
let failed = false;
|
|
738
750
|
let closing = '';
|
|
751
|
+
/* The transcript entry the closing message landed on, so a questions block
|
|
752
|
+
the operator answers stays tied to the message that asked. */
|
|
753
|
+
let closingSequence = 0;
|
|
739
754
|
/* This snapshot is the enforcement point for role ownership. The workspace
|
|
740
755
|
remains readable to the agent, but changes outside its module allowlist are
|
|
741
756
|
quarantined and restored before any formatter, gate, checkpoint or delivery
|
|
@@ -758,14 +773,18 @@ export async function* runTurn(
|
|
|
758
773
|
model: session.model,
|
|
759
774
|
signal: input.signal,
|
|
760
775
|
})) {
|
|
761
|
-
|
|
776
|
+
const recorded = await appendChatEntry(context.workspaceRoot, session, {
|
|
762
777
|
kind: eventKind(event),
|
|
763
778
|
role: roleId,
|
|
764
779
|
module: active.directory,
|
|
765
780
|
...(event.type === 'assistant.message' ? { text: event.text } : {}),
|
|
766
781
|
event,
|
|
767
782
|
});
|
|
768
|
-
|
|
783
|
+
yield recorded;
|
|
784
|
+
if (event.type === 'assistant.message') {
|
|
785
|
+
closing = event.text;
|
|
786
|
+
closingSequence = recorded.sequence;
|
|
787
|
+
}
|
|
769
788
|
if (event.type === 'turn.completed') {
|
|
770
789
|
nextResumeId = event.resumeId ?? nextResumeId;
|
|
771
790
|
failed = event.finishReason === 'error';
|
|
@@ -809,6 +828,19 @@ export async function* runTurn(
|
|
|
809
828
|
});
|
|
810
829
|
}
|
|
811
830
|
|
|
831
|
+
/* A specialist that needs decisions closes with a questions block. A block
|
|
832
|
+
the protocol cannot read is a warning on this turn, never a failure: the
|
|
833
|
+
words of the reply still stand. */
|
|
834
|
+
const asked = closing ? readQuestions(closing) : ({ kind: 'none' } as const);
|
|
835
|
+
if (asked.kind === 'invalid') {
|
|
836
|
+
yield await appendChatEntry(context.workspaceRoot, session, {
|
|
837
|
+
kind: 'system',
|
|
838
|
+
role: roleId,
|
|
839
|
+
module: active.directory,
|
|
840
|
+
text: `The questions block in this reply was ignored: ${asked.reason}`,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
812
844
|
const scaffoldNote =
|
|
813
845
|
failed || reviewing ? null : await scaffoldFromSpec(context, session);
|
|
814
846
|
if (scaffoldNote) {
|
|
@@ -1013,6 +1045,16 @@ export async function* runTurn(
|
|
|
1013
1045
|
if (nextResumeId) resumeIds[resumeKey] = nextResumeId;
|
|
1014
1046
|
const updated = await updateSession(context.workspaceRoot, session.id, {
|
|
1015
1047
|
resumeIds,
|
|
1048
|
+
pendingQuestions:
|
|
1049
|
+
asked.kind === 'valid' && !failed
|
|
1050
|
+
? {
|
|
1051
|
+
sequence: closingSequence,
|
|
1052
|
+
role: roleId,
|
|
1053
|
+
module: active.directory,
|
|
1054
|
+
askedAt: Date.now(),
|
|
1055
|
+
questions: asked.questions,
|
|
1056
|
+
}
|
|
1057
|
+
: null,
|
|
1016
1058
|
state: failed
|
|
1017
1059
|
? 'failed'
|
|
1018
1060
|
: handoff.kind === 'approval' || handoff.kind === 'question'
|