@blockrun/franklin 3.15.1 → 3.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/tool-guard.d.ts +1 -0
- package/dist/agent/tool-guard.js +31 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/detach.js +10 -7
- package/dist/ui/app.js +46 -13
- package/package.json +1 -1
package/dist/agent/tool-guard.js
CHANGED
|
@@ -38,6 +38,13 @@ function globKey(invocation) {
|
|
|
38
38
|
const path = normalizePath(String(invocation.input.path ?? ''));
|
|
39
39
|
return `${pattern}::${path}`;
|
|
40
40
|
}
|
|
41
|
+
// Detect a blocking poll-loop in a foreground bash command:
|
|
42
|
+
// any `for|while|until` loop containing a `sleep` of ≥1 second. This is
|
|
43
|
+
// the canonical antipattern that makes the agent feel frozen — see
|
|
44
|
+
// beforeBash() for the full rationale and the recommended alternatives.
|
|
45
|
+
// Use [\s\S] for cross-line match so we catch multi-line scripts; require
|
|
46
|
+
// `sleep [1-9]` so trivial `sleep 0` / `sleep 0.1` micro-pauses don't trip.
|
|
47
|
+
export const BLOCKING_POLL_LOOP_RE = /\b(?:for|while|until)\b[\s\S]*?\bsleep\s+[1-9]/;
|
|
41
48
|
const WRITE_KEYWORDS = (() => {
|
|
42
49
|
const words = [
|
|
43
50
|
'rm', 'mv', 'cp', 'mkdir', 'touch', 'chmod', 'chown', 'ln',
|
|
@@ -212,6 +219,30 @@ export class SessionToolGuard {
|
|
|
212
219
|
const cmd = String(invocation.input.command ?? '').trim();
|
|
213
220
|
if (!cmd)
|
|
214
221
|
return null;
|
|
222
|
+
// Reject blocking poll-loops in foreground bash. A single bash call with
|
|
223
|
+
// `sleep N` inside a for/while/until loop blocks the agent for the full
|
|
224
|
+
// duration — the UI repeats the same status line and the user almost
|
|
225
|
+
// always cancels before it finishes. The right pattern is `Detach`
|
|
226
|
+
// (persistent background task) or `run_in_background: true`.
|
|
227
|
+
const runInBackground = Boolean(invocation.input.run_in_background);
|
|
228
|
+
if (!runInBackground && BLOCKING_POLL_LOOP_RE.test(cmd)) {
|
|
229
|
+
return {
|
|
230
|
+
output: 'Blocked: this Bash command runs `sleep` inside a for/while/until loop in the ' +
|
|
231
|
+
'foreground. That blocks the agent for the full poll duration and looks frozen ' +
|
|
232
|
+
'to the user — they almost always cancel before it finishes.\n\n' +
|
|
233
|
+
'Use the `Detach` tool for polling-style work (waiting for an Apify run, video ' +
|
|
234
|
+
'generation, deploy, build, or any external async job to complete). It returns ' +
|
|
235
|
+
'a runId immediately and the polling continues persistently. Check status later ' +
|
|
236
|
+
'with `franklin task wait <runId>` or `franklin task tail <runId>` via a ' +
|
|
237
|
+
'separate Bash call.\n\n' +
|
|
238
|
+
'If you need the result inline, break the loop into discrete single-poll Bash ' +
|
|
239
|
+
'calls — poll once, reason about the status, then decide whether to poll again. ' +
|
|
240
|
+
'Or, if the upstream API has a sync variant (e.g. Apify\'s ' +
|
|
241
|
+
'`run-sync-get-dataset-items`), use that with a `timeout` of 300000–600000 ms ' +
|
|
242
|
+
'instead of orchestrating async + poll yourself.',
|
|
243
|
+
isError: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
215
246
|
// Only dedup deterministic read-only commands. Skip anything writing/network/long-running.
|
|
216
247
|
if (WRITE_KEYWORDS.test(cmd))
|
|
217
248
|
return null;
|
package/dist/tools/bash.js
CHANGED
|
@@ -511,6 +511,10 @@ IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \
|
|
|
511
511
|
- Avoid unnecessary \`sleep\` commands:
|
|
512
512
|
- Do not sleep between commands that can run immediately — just run them.
|
|
513
513
|
- Do not retry failing commands in a sleep loop — diagnose the root cause.
|
|
514
|
+
- Do NOT write \`sleep\` inside a for/while/until loop in a single foreground Bash call to poll an external async job. That blocks the agent for the whole poll duration and looks frozen to the user; they will cancel before it finishes. Pick one:
|
|
515
|
+
1. Use the \`Detach\` tool for polling-style work (waiting for an Apify run, video generation, deploy, or build to complete). It returns a runId immediately and the polling runs persistently; check status later with \`franklin task wait/tail <runId>\`.
|
|
516
|
+
2. Use the upstream sync endpoint when one exists (e.g. Apify's \`run-sync-get-dataset-items\`) with an explicit \`timeout\` up to 600000ms — usually simpler than orchestrating async + poll yourself.
|
|
517
|
+
3. Break the poll into discrete single-call polls — one poll per Bash call, reason about the status between calls, decide whether to poll again. The user can then see progress and course-correct.
|
|
514
518
|
|
|
515
519
|
Output is capped at 512KB capture / 32KB return.`,
|
|
516
520
|
input_schema: {
|
package/dist/tools/detach.js
CHANGED
|
@@ -32,13 +32,16 @@ export const detachCapability = {
|
|
|
32
32
|
description: "Run a Bash command as a detached background job. Returns immediately " +
|
|
33
33
|
"with a runId. The command continues even if Franklin exits or the user " +
|
|
34
34
|
"closes their terminal. Use this for any iteration over more than ~20 " +
|
|
35
|
-
"items, large data fetches, paginated API loops,
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"<runId
|
|
35
|
+
"items, large data fetches, paginated API loops, polling external async " +
|
|
36
|
+
"jobs (waiting for an Apify run / video generation / deploy / build to " +
|
|
37
|
+
"complete), or anything you'd otherwise loop on turn-by-turn (which " +
|
|
38
|
+
"would burn turns and trip timeouts). The agent's job is to design and " +
|
|
39
|
+
"orchestrate, not to be the for-loop. Pair with a script that writes a " +
|
|
40
|
+
"checkpoint file so progress survives restarts. Tail logs with " +
|
|
41
|
+
"`franklin task tail <runId> --follow` and check completion with " +
|
|
42
|
+
"`franklin task wait <runId>`. ALWAYS prefer Detach over a single " +
|
|
43
|
+
"foreground Bash call with `sleep` inside a for/while/until loop — that " +
|
|
44
|
+
"antipattern blocks the agent for the full duration and looks frozen.",
|
|
42
45
|
input_schema: {
|
|
43
46
|
type: 'object',
|
|
44
47
|
properties: {
|
package/dist/ui/app.js
CHANGED
|
@@ -99,6 +99,11 @@ function formatAgentErrorForDisplay(error) {
|
|
|
99
99
|
}
|
|
100
100
|
function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) {
|
|
101
101
|
const { exit } = useApp();
|
|
102
|
+
// Track terminal rows so we can cap the dynamic-region height. Ink wipes the
|
|
103
|
+
// terminal scrollback (via ansiEscapes.clearTerminal → \x1b[3J) whenever the
|
|
104
|
+
// dynamic output exceeds rows, so any tall live region (streaming text,
|
|
105
|
+
// model picker) must be windowed to preserve "scroll to the start" history.
|
|
106
|
+
const { rows: termRows } = useTerminalSize();
|
|
102
107
|
const [input, setInput] = useState('');
|
|
103
108
|
const [streamText, setStreamText] = useState('');
|
|
104
109
|
const [thinking, setThinking] = useState(false);
|
|
@@ -761,7 +766,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
761
766
|
setAskUserRequest(null);
|
|
762
767
|
setAskUserInput('');
|
|
763
768
|
r(answer);
|
|
764
|
-
}, focus: true })] })] })), expandableTool && (() => {
|
|
769
|
+
}, focus: true })] })] })), expandableTool && !permissionRequest && !askUserRequest && (() => {
|
|
765
770
|
const tool = expandableTool;
|
|
766
771
|
const elapsedFmt = tool.elapsed >= 1000
|
|
767
772
|
? `${(tool.elapsed / 1000).toFixed(1)}s`
|
|
@@ -776,18 +781,46 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
776
781
|
const lines = thinkingText.split('\n').filter(Boolean).slice(-3);
|
|
777
782
|
return (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: lines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }));
|
|
778
783
|
})()] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsxs(Text, { dimColor: true, children: [shortModelName(currentModel), completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), streamText && (() => {
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
784
|
+
const maxLines = Math.max(8, termRows - 12);
|
|
785
|
+
const lines = streamText.split('\n');
|
|
786
|
+
const truncated = lines.length > maxLines;
|
|
787
|
+
const visible = truncated ? lines.slice(-maxLines).join('\n') : streamText;
|
|
788
|
+
const { rendered, partial } = renderMarkdownStreaming(visible);
|
|
789
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 0, marginBottom: 0, marginLeft: 2, children: [truncated && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", lines.length - maxLines, " earlier line", lines.length - maxLines === 1 ? '' : 's', " \u2014 full response will appear in scrollback when this turn finishes"] })), _jsxs(Text, { wrap: "wrap", children: [rendered, rendered && partial ? '\n' : '', partial] })] }));
|
|
790
|
+
})(), responsePreview && !streamText && !permissionRequest && !askUserRequest && (_jsx(Box, { flexDirection: "column", marginBottom: 0, marginLeft: 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
|
|
791
|
+
const totalModels = PICKER_MODELS_FLAT.length;
|
|
792
|
+
const maxModels = Math.max(6, termRows - 12);
|
|
793
|
+
let start = Math.max(0, pickerIdx - Math.floor(maxModels / 2));
|
|
794
|
+
let end = Math.min(totalModels, start + maxModels);
|
|
795
|
+
// Expand window backward if we hit the bottom of the list, so we
|
|
796
|
+
// always fill `maxModels` rows when the list is long enough.
|
|
797
|
+
if (end - start < maxModels)
|
|
798
|
+
start = Math.max(0, end - maxModels);
|
|
799
|
+
const hiddenAbove = start;
|
|
800
|
+
const hiddenBelow = totalModels - end;
|
|
801
|
+
// Pre-compute each category's base offset into the flat model list so
|
|
802
|
+
// we can map (cat, localIdx) → globalIdx in one pass without re-walking.
|
|
803
|
+
let cursor = 0;
|
|
804
|
+
const catBases = PICKER_CATEGORIES.map((cat) => {
|
|
805
|
+
const base = cursor;
|
|
806
|
+
cursor += cat.models.length;
|
|
807
|
+
return base;
|
|
808
|
+
});
|
|
809
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), hiddenAbove > 0 && (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2191 ", hiddenAbove, " more above"] }) })), PICKER_CATEGORIES.map((cat, catIdx) => {
|
|
810
|
+
const base = catBases[catIdx];
|
|
811
|
+
const visible = cat.models
|
|
812
|
+
.map((m, localIdx) => ({ m, globalIdx: base + localIdx }))
|
|
813
|
+
.filter(({ globalIdx }) => globalIdx >= start && globalIdx < end);
|
|
814
|
+
if (visible.length === 0)
|
|
815
|
+
return null;
|
|
816
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), visible.map(({ m, globalIdx }) => {
|
|
817
|
+
const isSelected = globalIdx === pickerIdx;
|
|
818
|
+
const isCurrent = m.id === currentModel;
|
|
819
|
+
const isHighlight = m.highlight === true;
|
|
820
|
+
return (_jsxs(Box, { marginLeft: 2, children: [_jsxs(Text, { inverse: isSelected, color: isSelected ? 'cyan' : isHighlight ? 'yellow' : undefined, bold: isSelected || isHighlight, children: [' ', m.label.padEnd(26), ' '] }), _jsxs(Text, { dimColor: true, children: [" ", m.shortcut.padEnd(14)] }), _jsx(Text, { color: m.price === 'FREE' ? 'green' : isHighlight ? 'yellow' : undefined, dimColor: !isHighlight && m.price !== 'FREE', children: m.price }), isCurrent && _jsx(Text, { color: "green", children: " \u2190" })] }, m.id));
|
|
821
|
+
})] }, cat.category));
|
|
822
|
+
}), hiddenBelow > 0 && (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2193 ", hiddenBelow, " more below"] }) })), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { dimColor: true, children: "Your conversation stays above \u2014 picking a model keeps all history intact." }) })] }));
|
|
823
|
+
})(), !inPicker && !permissionRequest && !askUserRequest && (_jsx(InputBox, { input: input, setInput: setInput, onSubmit: handleSubmit, model: currentModel, balance: liveBalance, chain: chain, walletTail: walletAddress && walletAddress.length >= 4 && !walletAddress.startsWith('not set') ? walletAddress.slice(-4) : undefined, sessionCost: totalCost, queued: queuedInputs[0] || undefined, queuedCount: queuedInputs.length, focused: !permissionRequest && !askUserRequest, busy: !askUserRequest && (waiting || thinking || tools.size > 0), contextPct: contextPct, vimMode: vimEnabled, onVimModeChange: setCurrentVimMode }))] }));
|
|
791
824
|
}
|
|
792
825
|
export function launchInkUI(opts) {
|
|
793
826
|
let resolveInput = null;
|
package/package.json
CHANGED