@gakim-digital/dexter-bridge 0.13.7 → 0.13.8
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/package.json +1 -1
- package/src/agent.js +16 -10
- package/src/framerTools.js +46 -7
- package/src/protocol.js +8 -5
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1233,12 +1233,15 @@ function runProcess(
|
|
|
1233
1233
|
stdoutProgress,
|
|
1234
1234
|
} = {},
|
|
1235
1235
|
) {
|
|
1236
|
+
// An abort without a reason carries a DOMException, whose code is read-only.
|
|
1237
|
+
const cancellationError = (reason) =>
|
|
1238
|
+
reason instanceof Error && !(reason instanceof DOMException)
|
|
1239
|
+
? Object.assign(reason, { code: reason.code || 'RUN_CANCELLED' })
|
|
1240
|
+
: Object.assign(new Error('The model process was cancelled.'), { code: 'RUN_CANCELLED' });
|
|
1236
1241
|
return new Promise((resolve, reject) => {
|
|
1237
1242
|
const stopped = [signal, bridgeShutdown.signal].find((candidate) => candidate?.aborted);
|
|
1238
1243
|
if (stopped) {
|
|
1239
|
-
|
|
1240
|
-
error.code = error.code || 'RUN_CANCELLED';
|
|
1241
|
-
reject(error);
|
|
1244
|
+
reject(cancellationError(stopped.reason));
|
|
1242
1245
|
return;
|
|
1243
1246
|
}
|
|
1244
1247
|
const started = Date.now();
|
|
@@ -1327,9 +1330,7 @@ function runProcess(
|
|
|
1327
1330
|
const abort = (event) => {
|
|
1328
1331
|
if (settled || terminationError) return;
|
|
1329
1332
|
clearTimers();
|
|
1330
|
-
const
|
|
1331
|
-
const error = reason instanceof Error ? reason : new Error('The model process was cancelled.');
|
|
1332
|
-
error.code = error.code || 'RUN_CANCELLED';
|
|
1333
|
+
const error = cancellationError(event?.target?.reason);
|
|
1333
1334
|
terminationError = error;
|
|
1334
1335
|
terminateChildTree(error);
|
|
1335
1336
|
trace?.info('agent_process_cancelled', {
|
|
@@ -2520,6 +2521,8 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
2520
2521
|
rejectCancellation(cancellationError());
|
|
2521
2522
|
return cancelAction;
|
|
2522
2523
|
};
|
|
2524
|
+
// Framer programs can run for minutes; Stop does not wait for them.
|
|
2525
|
+
const unlessCancelled = (work) => Promise.race([work, cancellation]);
|
|
2523
2526
|
const postActivity = (payload) => {
|
|
2524
2527
|
activityChain = activityChain
|
|
2525
2528
|
.then(async () => {
|
|
@@ -2633,12 +2636,15 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
2633
2636
|
occurredAt: new Date().toISOString(),
|
|
2634
2637
|
message: `${definition.label} is still working.`,
|
|
2635
2638
|
});
|
|
2639
|
+
// The run ends on the cancellation itself; a provider that is slow to
|
|
2640
|
+
// stop must not hold up the cancelled event.
|
|
2636
2641
|
if (controlRequestsCancellation(response)) {
|
|
2637
|
-
|
|
2642
|
+
void requestCancel(controlCancellationSource(response));
|
|
2643
|
+
return;
|
|
2638
2644
|
}
|
|
2639
2645
|
} catch (error) {
|
|
2640
2646
|
if (isCompanionPairingInvalidError(error)) {
|
|
2641
|
-
|
|
2647
|
+
void requestCancel('device_disconnect');
|
|
2642
2648
|
return;
|
|
2643
2649
|
}
|
|
2644
2650
|
if (!controlMonitorAbort.signal.aborted && !cancelRequested) {
|
|
@@ -3132,7 +3138,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
3132
3138
|
let previousRepairPrompt = null;
|
|
3133
3139
|
for (let round = 0; round < OUTCOME_REPAIR_ROUNDS; round += 1) {
|
|
3134
3140
|
if (typeof harnessTools.assessCompletion === 'function') {
|
|
3135
|
-
await harnessTools.assessCompletion(rawCompletion);
|
|
3141
|
+
await unlessCancelled(harnessTools.assessCompletion(rawCompletion));
|
|
3136
3142
|
assessedCompletion = rawCompletion;
|
|
3137
3143
|
}
|
|
3138
3144
|
const unappliedChangePrompt =
|
|
@@ -3205,7 +3211,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
3205
3211
|
});
|
|
3206
3212
|
}
|
|
3207
3213
|
if (typeof harnessTools.assessCompletion === 'function' && assessedCompletion !== rawCompletion) {
|
|
3208
|
-
await harnessTools.assessCompletion(rawCompletion);
|
|
3214
|
+
await unlessCancelled(harnessTools.assessCompletion(rawCompletion));
|
|
3209
3215
|
}
|
|
3210
3216
|
const completion =
|
|
3211
3217
|
typeof harnessTools.finalizeCompletion === 'function'
|
package/src/framerTools.js
CHANGED
|
@@ -546,12 +546,37 @@ const __dexterTreeRecorders = {
|
|
|
546
546
|
makeExternalComponentLocal: (args, result) => __dexterNote(args[0], result),
|
|
547
547
|
};
|
|
548
548
|
const __dexterTreeTarget = { insert: 1 };
|
|
549
|
+
// CMS collections, items, and fields, and pages, are deleted only with the
|
|
550
|
+
// user's permission for this run. The target is read before the call runs.
|
|
551
|
+
const __dexterRefused = [];
|
|
552
|
+
const __dexterContentTypes = new Set(['CollectionNode', 'CollectionItemNode', 'WebPageNode', 'DesignPageNode']);
|
|
553
|
+
const __dexterRefuse = (target, content) => {
|
|
554
|
+
__dexterRefused.push({ target, content });
|
|
555
|
+
throw new Error('Deleting ' + content + ' needs the user\\'s permission. Nothing was deleted.');
|
|
556
|
+
};
|
|
557
|
+
const __dexterTreeGuards = __dexterDeletionAllowed ? {} : {
|
|
558
|
+
remove: (tree, args) => {
|
|
559
|
+
const id = __dexterIdOf(args[0]);
|
|
560
|
+
const node = id ? tree.get(id) : null;
|
|
561
|
+
if (node && __dexterContentTypes.has(node.type)) __dexterRefuse(id, node.type + ' "' + (node.name || id) + '"');
|
|
562
|
+
},
|
|
563
|
+
patch: (tree, args) => {
|
|
564
|
+
const id = __dexterIdOf(args[0]);
|
|
565
|
+
const next = args[1] && args[1].variables;
|
|
566
|
+
if (!id || !Array.isArray(next)) return;
|
|
567
|
+
const node = tree.get(id);
|
|
568
|
+
if (!node || node.type !== 'CollectionNode' || !Array.isArray(node.variables)) return;
|
|
569
|
+
const kept = new Set(next.map((variable) => variable && variable.id));
|
|
570
|
+
const dropped = node.variables.filter((variable) => variable && variable.id && !kept.has(variable.id));
|
|
571
|
+
if (dropped.length) __dexterRefuse(id, 'the fields ' + dropped.map((variable) => '"' + (variable.name || variable.id) + '"').join(', ') + ' of collection "' + (node.name || id) + '"');
|
|
572
|
+
},
|
|
573
|
+
};
|
|
549
574
|
const __dexterFileRecorders = Object.fromEntries(['create', 'write', 'applyPatch', 'delete'].map((op) => [op, (args, result) => {
|
|
550
575
|
__dexterFiles.push({ op, path: typeof args[0] === 'string' ? args[0] : '', diagnostics: result && Array.isArray(result.diagnostics) ? result.diagnostics : [] });
|
|
551
576
|
}]));
|
|
552
577
|
// The proxy sits on an empty stand-in that forwards to the real object, so
|
|
553
578
|
// methods can be wrapped even when Framer freezes its API objects.
|
|
554
|
-
const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}) => new Proxy({}, {
|
|
579
|
+
const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}, guards = {}) => new Proxy({}, {
|
|
555
580
|
has: (_, key) => Reflect.has(object, key),
|
|
556
581
|
ownKeys: () => Reflect.ownKeys(object),
|
|
557
582
|
getOwnPropertyDescriptor: (_, key) => {
|
|
@@ -572,6 +597,7 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
|
|
|
572
597
|
}
|
|
573
598
|
throw error;
|
|
574
599
|
};
|
|
600
|
+
if (guards[key]) guards[key](object, args);
|
|
575
601
|
const layer = checked ? __dexterLayerOf(args[0]) : null;
|
|
576
602
|
const before = checked ? __dexterStateOf(object, layer) : null;
|
|
577
603
|
const settle = (result) => {
|
|
@@ -591,7 +617,7 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
|
|
|
591
617
|
},
|
|
592
618
|
});
|
|
593
619
|
const __dexterApi = __dexterRecording(api, {}, {
|
|
594
|
-
tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget),
|
|
620
|
+
tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget, __dexterTreeGuards),
|
|
595
621
|
files: (files) => __dexterRecording(files, __dexterFileRecorders),
|
|
596
622
|
});
|
|
597
623
|
`;
|
|
@@ -714,12 +740,13 @@ export function wrappedReadLineOffset(program) {
|
|
|
714
740
|
return readParts(program).prefix.split('\n').length - 1;
|
|
715
741
|
}
|
|
716
742
|
|
|
717
|
-
function wrapperParts(program) {
|
|
743
|
+
function wrapperParts(program, { allowContentDeletion = false } = {}) {
|
|
718
744
|
const { step, body } = splitStep(program);
|
|
719
745
|
// A program that throws still keeps the changes it made before the throw,
|
|
720
746
|
// so the error is caught here and returned with the record of those changes.
|
|
721
747
|
const prefix = `${step}
|
|
722
748
|
${MEASURE_PRELUDE}
|
|
749
|
+
const __dexterDeletionAllowed = ${allowContentDeletion === true};
|
|
723
750
|
${RECORDING_PRELUDE}
|
|
724
751
|
let __dexterResult;
|
|
725
752
|
let __dexterError = null;
|
|
@@ -731,13 +758,13 @@ try {
|
|
|
731
758
|
} catch (error) {
|
|
732
759
|
__dexterError = { name: String((error && error.name) || 'Error'), message: String((error && error.message) || error) };
|
|
733
760
|
}
|
|
734
|
-
return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged };
|
|
761
|
+
return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged, dexterRefused: __dexterRefused };
|
|
735
762
|
`;
|
|
736
763
|
return { prefix, body, suffix };
|
|
737
764
|
}
|
|
738
765
|
|
|
739
|
-
export function wrapProgram(program) {
|
|
740
|
-
const { prefix, body, suffix } = wrapperParts(program);
|
|
766
|
+
export function wrapProgram(program, options) {
|
|
767
|
+
const { prefix, body, suffix } = wrapperParts(program, options);
|
|
741
768
|
return `${prefix}${body}${suffix}`;
|
|
742
769
|
}
|
|
743
770
|
|
|
@@ -938,6 +965,7 @@ export function createFramerAgentToolRuntime({
|
|
|
938
965
|
const projectUrl = String(context.framerProjectUrl || '').trim();
|
|
939
966
|
const activePageId = String(context.activePageId || '').trim();
|
|
940
967
|
const allowPublishing = context.permissions?.publish === true;
|
|
968
|
+
const allowContentDeletion = context.permissions?.deleteContent === true;
|
|
941
969
|
const allowedTools = Array.isArray(assignment?.toolProtocol?.tools)
|
|
942
970
|
? new Set(assignment.toolProtocol.tools)
|
|
943
971
|
: null;
|
|
@@ -1372,9 +1400,20 @@ export function createFramerAgentToolRuntime({
|
|
|
1372
1400
|
callExtra.helpers = helpersUsed(program);
|
|
1373
1401
|
await ensureProject();
|
|
1374
1402
|
await snapshotUnchanged();
|
|
1375
|
-
const result = await client.exec(wrapProgram(program));
|
|
1403
|
+
const result = await client.exec(wrapProgram(program, { allowContentDeletion }));
|
|
1376
1404
|
const applied = applyReceipt(result, screenshotNodeIds);
|
|
1377
1405
|
requirements = renameRequirementNodes(requirements, applied.receipt.renamedIds);
|
|
1406
|
+
const refused = Array.isArray(applied.output?.result?.dexterRefused) ? applied.output.result.dexterRefused : [];
|
|
1407
|
+
if (refused.length) {
|
|
1408
|
+
throw toolError(
|
|
1409
|
+
'FRAMER_AGENT_DELETE_NOT_AUTHORIZED',
|
|
1410
|
+
[
|
|
1411
|
+
`Deleting ${refused.map((entry) => entry.content).join('; ')} needs the user's clear permission in this conversation, which they have not given, so nothing was deleted.`,
|
|
1412
|
+
applied.changed ? 'The other changes this program made before that point were kept.' : '',
|
|
1413
|
+
'If deleting it is still the right step, ask the user one plain question naming exactly what you would delete, and return blocked. When they agree, Dexter gives you that permission in the next run.',
|
|
1414
|
+
].filter(Boolean).join(' '),
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1378
1417
|
const programError = programErrorOf(applied.output, wrappedLineOffset(program));
|
|
1379
1418
|
// Layers created by this program only get their real ids once it
|
|
1380
1419
|
// finishes, so screenshots are taken in a second call.
|
package/src/protocol.js
CHANGED
|
@@ -511,12 +511,14 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
|
|
|
511
511
|
'A greeting, thanks, or question that needs no project change is not blocked: answer it in summary without reading or editing the project and return completed.',
|
|
512
512
|
'Set projectChanged to true only when this run applied a change to the Framer project, and list the ids of the layers you changed in changedNodeIds. The summary must describe only changes that were applied. If a requirement is still not met, or you could not find or change something, name it plainly in the summary; never describe it as done.',
|
|
513
513
|
'Some requests need something beyond the layers you may change, such as photos on other pages, a component shared across the site, or site-wide styles. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked; when they agree, Dexter gives you that reach in the next run. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way, such as which shared setting causes it. Never mention access, scope, permissions, grants, or authorization.',
|
|
514
|
+
'Deleting CMS collections, items, or fields, or pages, needs the user\'s clear permission in this conversation; context.permissions.deleteContent is true when they gave it. A request to change, fix, or replace content is not permission to delete it. Without it, ask one plain question naming exactly what you would delete and return blocked, before building any replacement for it.',
|
|
514
515
|
'If you changed layers before asking a question, say in your summary what you changed.',
|
|
515
516
|
'When you return blocked, write the question itself in summary, since summary is what the user reads; blockedReason is a short note for Dexter.',
|
|
517
|
+
'summary is the only text the user reads; checks are for Dexter and are never shown to them. Write summary in Markdown. After an edit, a short, plain answer about what changed is enough. When the user asks a question or asks for a report, audit, review, list, or plan, summary is the full answer, formatted with headings, lists, or tables where they help: never shorten it to a line or put it in checks.',
|
|
516
518
|
'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep layer names and quoted text as they are.',
|
|
517
519
|
REPAIR_SUMMARY_RULE,
|
|
518
|
-
'When finished, return exactly one JSON object
|
|
519
|
-
'{"status":"completed|blocked|failed","summary":"
|
|
520
|
+
'When finished, return exactly one JSON object with nothing around it. Markdown belongs only inside the summary string.',
|
|
521
|
+
'{"status":"completed|blocked|failed","summary":"your answer to the designer, in Markdown","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null,"projectChanged":false,"changedNodeIds":[]}',
|
|
520
522
|
doctrine
|
|
521
523
|
? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
|
|
522
524
|
: '',
|
|
@@ -581,8 +583,9 @@ export function buildWebflowOutcomePrompt(outcome = {}, product, promptContext =
|
|
|
581
583
|
'The summary must describe only changes that were applied. If something the user asked for is not done, name it plainly; never describe it as done.',
|
|
582
584
|
'Some requests need something beyond the elements you may change, such as content on other pages or a component used across the site. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way. If you changed elements before asking, say in your summary what you changed. Never mention access, scope, permissions, grants, or authorization.',
|
|
583
585
|
'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep element names and quoted text as they are.',
|
|
584
|
-
'When
|
|
585
|
-
'
|
|
586
|
+
'summary is the only text the user reads; checks are for Dexter and are never shown to them. Write summary in Markdown. After an edit, a short, plain answer about what changed is enough. When the user asks a question or asks for a report, audit, review, list, or plan, summary is the full answer, formatted with headings, lists, or tables where they help: never shorten it to a line or put it in checks.',
|
|
587
|
+
'When finished, return exactly one JSON object with nothing around it. Markdown belongs only inside the summary string.',
|
|
588
|
+
'{"status":"completed|blocked|failed","summary":"your answer to the designer, in Markdown","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null}',
|
|
586
589
|
doctrine
|
|
587
590
|
? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
|
|
588
591
|
: '',
|
|
@@ -759,7 +762,7 @@ export function normalizeOutcomeCompletion(
|
|
|
759
762
|
status,
|
|
760
763
|
summary:
|
|
761
764
|
typeof raw.summary === 'string' && raw.summary.trim()
|
|
762
|
-
? raw.summary.trim().slice(0,
|
|
765
|
+
? raw.summary.trim().slice(0, 40_000)
|
|
763
766
|
: 'The harness did not provide a summary.',
|
|
764
767
|
checks,
|
|
765
768
|
blockedReason:
|