factoryhub 0.1.0 → 0.1.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 +9 -3
- package/factories/index.json +231 -0
- package/factories/newsletter-note/README.md +11 -0
- package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
- package/factories/newsletter-note/agents/writer/agent.md +11 -0
- package/factories/newsletter-note/factory.yaml +12 -0
- package/factories/newsletter-note/flow.yaml +45 -0
- package/factories/newsletter-note/mindburn.tests.yaml +80 -0
- package/factories/release-desk/README.md +13 -0
- package/factories/release-desk/agents/editor/agent.md +10 -0
- package/factories/release-desk/agents/reviewer/agent.md +10 -0
- package/factories/release-desk/factory.yaml +13 -0
- package/factories/release-desk/flow.yaml +67 -0
- package/factories/release-desk/mindburn.tests.yaml +163 -0
- package/factories/short-video-cut/README.md +11 -0
- package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
- package/factories/short-video-cut/agents/writer/agent.md +11 -0
- package/factories/short-video-cut/factory.yaml +13 -0
- package/factories/short-video-cut/flow.yaml +46 -0
- package/factories/short-video-cut/mindburn.tests.yaml +80 -0
- package/factories/video-facts/README.md +11 -0
- package/factories/video-facts/agents/analyst/agent.md +10 -0
- package/factories/video-facts/agents/reviewer/agent.md +10 -0
- package/factories/video-facts/factory.yaml +13 -0
- package/factories/video-facts/flow.yaml +39 -0
- package/factories/video-facts/mindburn.tests.yaml +34 -0
- package/factories/video-to-posts/README.md +35 -0
- package/factories/video-to-posts/agents/analyst/agent.md +10 -0
- package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
- package/factories/video-to-posts/agents/writer/agent.md +11 -0
- package/factories/video-to-posts/factory.yaml +12 -0
- package/factories/video-to-posts/flow.yaml +62 -0
- package/factories/video-to-posts/mindburn.tests.yaml +34 -0
- package/factories/week-of-posts/README.md +11 -0
- package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
- package/factories/week-of-posts/agents/writer/agent.md +11 -0
- package/factories/week-of-posts/factory.yaml +12 -0
- package/factories/week-of-posts/flow.yaml +46 -0
- package/factories/week-of-posts/mindburn.tests.yaml +80 -0
- package/package.json +15 -2
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/export.mjs +0 -1
- package/packages/cli/src/commands/get.mjs +0 -1
- package/packages/cli/src/commands/inspect.mjs +0 -1
- package/packages/cli/src/commands/list.mjs +1 -6
- package/packages/cli/src/commands/login.mjs +161 -0
- package/packages/cli/src/commands/logout.mjs +43 -0
- package/packages/cli/src/commands/mcp.mjs +0 -1
- package/packages/cli/src/commands/new.mjs +0 -1
- package/packages/cli/src/commands/pack.mjs +0 -1
- package/packages/cli/src/commands/publish.mjs +62 -0
- package/packages/cli/src/commands/pull.mjs +59 -0
- package/packages/cli/src/commands/push.mjs +115 -0
- package/packages/cli/src/commands/resume.mjs +0 -1
- package/packages/cli/src/commands/run.mjs +182 -20
- package/packages/cli/src/commands/runs.mjs +52 -0
- package/packages/cli/src/commands/search.mjs +0 -1
- package/packages/cli/src/commands/validate.mjs +1 -3
- package/packages/cli/src/commands/whoami.mjs +34 -0
- package/packages/cli/src/hosted.mjs +159 -0
- package/packages/cli/src/main.mjs +80 -14
- package/packages/cli/src/tool-sources.mjs +272 -0
- package/packages/cli/src/util.mjs +37 -0
- package/packages/engine/lib/credentials.mjs +121 -0
- package/packages/engine/lib/hosted.mjs +119 -0
- package/packages/engine/lib/hub.mjs +376 -24
- package/packages/engine/lib/nango.mjs +157 -0
- package/packages/engine/lib/node.mjs +161 -33
- package/packages/engine/lib/providers.mjs +8 -3
- package/packages/engine/lib/run.mjs +70 -9
- package/packages/engine/lib/text.mjs +5 -0
- package/packages/engine/package.json +8 -2
- package/packages/engine/src/factory-engine.mjs +415 -68
- package/packages/engine/src/flow-sources.mjs +135 -0
- package/packages/engine/src/mcp-client.mjs +429 -0
- package/packages/engine/src/run.mjs +81 -5
- package/packages/engine/src/tool-sources.mjs +566 -0
- package/packages/mcp/package.json +1 -1
- package/packages/mcp/src/server.mjs +47 -17
- package/packages/sdk/package.json +28 -0
- package/packages/sdk/src/index.d.ts +163 -0
- package/packages/sdk/src/index.mjs +266 -0
- package/spec/README.md +7 -1
- package/spec/factory-format.md +264 -19
- package/spec/schema/factory.yaml.json +815 -0
- package/spec/schema/flow.schema.json +24 -2
- package/spec/skill-export.md +3 -0
|
@@ -267,7 +267,10 @@ export function dependentsOf(factory, id) {
|
|
|
267
267
|
return factory.steps.filter((step) => step.id !== id && ancestorsOf(factory, step.id).includes(id)).map((step) => step.id);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
// `features`: execution features a caller opts in to beyond REQUIRED_FEATURES
|
|
271
|
+
// (the experimental tool sources pass `tool-sources`). Without it, nothing
|
|
272
|
+
// changes: a flow that requires an unknown feature is refused.
|
|
273
|
+
export function parseBundle(input, { features = [] } = {}) {
|
|
271
274
|
const files = normalizeFiles(input);
|
|
272
275
|
const errors = [];
|
|
273
276
|
const warnings = [];
|
|
@@ -322,7 +325,7 @@ export function parseBundle(input) {
|
|
|
322
325
|
}
|
|
323
326
|
|
|
324
327
|
if (flow && typeof flow === 'object') {
|
|
325
|
-
if (flow.requires !== undefined && (!Array.isArray(flow.requires) || flow.requires.some((feature) => !REQUIRED_FEATURES.includes(feature)))) errors.push('This flow requires unsupported execution features. Use a compatible runner.');
|
|
328
|
+
if (flow.requires !== undefined && (!Array.isArray(flow.requires) || flow.requires.some((feature) => !REQUIRED_FEATURES.includes(feature) && !features.includes(feature)))) errors.push('This flow requires unsupported execution features. Use a compatible runner.');
|
|
326
329
|
factory.title = String(flow.title || '');
|
|
327
330
|
factory.author = String(flow.author || '');
|
|
328
331
|
factory.version = String(flow.version ?? '1.0.0');
|
|
@@ -692,32 +695,229 @@ function decodeEntities(text) {
|
|
|
692
695
|
.replace(/&([a-z]+);/gi, (whole, name) => ENTITIES[name.toLowerCase()] ?? whole);
|
|
693
696
|
}
|
|
694
697
|
|
|
698
|
+
// Pages are read only this far: past it the text is cut, and the reader is told.
|
|
699
|
+
const PAGE_CHARS = 2000000;
|
|
700
|
+
|
|
701
|
+
// Sorted start positions of every match of a global pattern.
|
|
702
|
+
const startsOf = (text, pattern) => Array.from(text.matchAll(pattern), (match) => match.index);
|
|
703
|
+
|
|
704
|
+
// The index of the first number in a sorted list that is at least `at`.
|
|
705
|
+
function indexFrom(list, at) {
|
|
706
|
+
let low = 0;
|
|
707
|
+
let high = list.length;
|
|
708
|
+
while (low < high) {
|
|
709
|
+
const middle = (low + high) >> 1;
|
|
710
|
+
if (list[middle] < at) low = middle + 1;
|
|
711
|
+
else high = middle;
|
|
712
|
+
}
|
|
713
|
+
return low;
|
|
714
|
+
}
|
|
715
|
+
// The first number in a sorted list that is at least `at`, or -1.
|
|
716
|
+
const firstFrom = (list, at) => list[indexFrom(list, at)] ?? -1;
|
|
717
|
+
|
|
718
|
+
// Applies a pattern that ends at the next ">", such as /<li[^>]*>/gi, to the
|
|
719
|
+
// text up to its last ">". No match can start after it, and leaving that part
|
|
720
|
+
// out keeps the scan linear: on a page of "<li" with no ">" the plain replace
|
|
721
|
+
// tries every "<li" to the end of the page.
|
|
722
|
+
function beforeLastGt(text, pattern, replacement) {
|
|
723
|
+
const end = text.lastIndexOf('>') + 1;
|
|
724
|
+
return text.slice(0, end).replace(pattern, replacement) + text.slice(end);
|
|
725
|
+
}
|
|
726
|
+
const stripTags = (text) => beforeLastGt(text, /<[^>]+>/g, ' ');
|
|
727
|
+
|
|
728
|
+
// The passes below find the same matches as the regular expressions in their
|
|
729
|
+
// comments, in time linear in the page. Those expressions search to the end
|
|
730
|
+
// of the page from every open tag that is never closed, and the link and meta
|
|
731
|
+
// ones backtrack over every attribute as well: 25 KB of such tags took seconds.
|
|
732
|
+
// An end tag is looked up in a sorted list of positions, and once an open tag
|
|
733
|
+
// cannot match, no later one inside the same tag is tried.
|
|
734
|
+
|
|
735
|
+
// The first match of /<title[^>]*>([\s\S]*?)<\/title>/i, or ''. Only the first
|
|
736
|
+
// "<title" can match: a later one ends at the same or a later ">".
|
|
737
|
+
function pageTitle(page) {
|
|
738
|
+
const open = page.search(/<title/i);
|
|
739
|
+
if (open < 0) return '';
|
|
740
|
+
const end = page.indexOf('>', open + 6);
|
|
741
|
+
if (end < 0) return '';
|
|
742
|
+
const close = /<\/title>/gi;
|
|
743
|
+
close.lastIndex = end + 1;
|
|
744
|
+
const found = close.exec(page);
|
|
745
|
+
return found ? page.slice(end + 1, found.index) : '';
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// The first match of either expression, or '':
|
|
749
|
+
// /<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i
|
|
750
|
+
// /<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i
|
|
751
|
+
// "[^>]+" is greedy, so the expressions take the last attribute that fits
|
|
752
|
+
// before the tag's first ">"; a content value may itself contain ">".
|
|
753
|
+
function pageDescription(page) {
|
|
754
|
+
const metas = startsOf(page, /<meta/gi);
|
|
755
|
+
if (!metas.length) return '';
|
|
756
|
+
const names = startsOf(page, /name=["']description["']/gi);
|
|
757
|
+
const contents = startsOf(page, /content=["']/gi);
|
|
758
|
+
const gts = startsOf(page, />/g);
|
|
759
|
+
const quotes = startsOf(page, /["']/g);
|
|
760
|
+
// Calls `each` on the listed positions in [from, to), last first, until it
|
|
761
|
+
// returns something other than null.
|
|
762
|
+
const lastFirst = (list, from, to, each) => {
|
|
763
|
+
for (let index = indexFrom(list, to) - 1; index >= 0 && list[index] >= from; index -= 1) {
|
|
764
|
+
const value = each(list[index]);
|
|
765
|
+
if (value !== null) return value;
|
|
766
|
+
}
|
|
767
|
+
return null;
|
|
768
|
+
};
|
|
769
|
+
// The first <meta that matches. One that does not match is followed by
|
|
770
|
+
// none that starts inside the same tag.
|
|
771
|
+
const firstMeta = (match) => {
|
|
772
|
+
let skip = -1;
|
|
773
|
+
for (const meta of metas) {
|
|
774
|
+
if (meta < skip) continue;
|
|
775
|
+
const gt = firstFrom(gts, meta + 5);
|
|
776
|
+
const value = match(meta, gt < 0 ? page.length : gt);
|
|
777
|
+
if (value !== null) return value;
|
|
778
|
+
if (gt < 0) return null;
|
|
779
|
+
skip = gt;
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
};
|
|
783
|
+
// name, then content: the last name with a content after it, then the last
|
|
784
|
+
// such content whose value a quote closes.
|
|
785
|
+
const nameFirst = firstMeta((meta, end) => lastFirst(names, meta + 6, end, (name) => lastFirst(contents, name + 18, end, (content) => {
|
|
786
|
+
const quote = firstFrom(quotes, content + 9);
|
|
787
|
+
return quote < 0 ? null : page.slice(content + 9, quote);
|
|
788
|
+
})));
|
|
789
|
+
if (nameFirst) return nameFirst;
|
|
790
|
+
// content, then name: the last content whose value is followed, before the
|
|
791
|
+
// next ">", by name="description".
|
|
792
|
+
return firstMeta((meta, end) => lastFirst(contents, meta + 6, end, (content) => {
|
|
793
|
+
const quote = firstFrom(quotes, content + 9);
|
|
794
|
+
if (quote < 0) return null;
|
|
795
|
+
const gt = firstFrom(gts, quote + 1);
|
|
796
|
+
const name = firstFrom(names, quote + 1);
|
|
797
|
+
return name >= 0 && (gt < 0 || name < gt) ? page.slice(content + 9, quote) : null;
|
|
798
|
+
})) || '';
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// /<!--[\s\S]*?-->/g, replaced by a space.
|
|
802
|
+
function dropComments(page) {
|
|
803
|
+
let out = '';
|
|
804
|
+
let at = 0;
|
|
805
|
+
for (;;) {
|
|
806
|
+
const open = page.indexOf('<!--', at);
|
|
807
|
+
if (open < 0) break;
|
|
808
|
+
const close = page.indexOf('-->', open + 4);
|
|
809
|
+
if (close < 0) break;
|
|
810
|
+
out += `${page.slice(at, open)} `;
|
|
811
|
+
at = close + 3;
|
|
812
|
+
}
|
|
813
|
+
return out + page.slice(at);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// /<(script|style|noscript|svg|template|iframe|canvas)\b[\s\S]*?<\/\1>/gi,
|
|
817
|
+
// replaced by a space.
|
|
818
|
+
function dropRawText(page) {
|
|
819
|
+
const closes = {};
|
|
820
|
+
for (const match of page.matchAll(/<\/(script|style|noscript|svg|template|iframe|canvas)>/gi)) (closes[match[1].toLowerCase()] ||= []).push(match.index);
|
|
821
|
+
const open = /<(script|style|noscript|svg|template|iframe|canvas)\b/gi;
|
|
822
|
+
let out = '';
|
|
823
|
+
let at = 0;
|
|
824
|
+
for (let match; (match = open.exec(page));) {
|
|
825
|
+
const name = match[1].toLowerCase();
|
|
826
|
+
const close = firstFrom(closes[name] || [], match.index + match[0].length);
|
|
827
|
+
if (close < 0) continue;
|
|
828
|
+
out += `${page.slice(at, match.index)} `;
|
|
829
|
+
at = close + name.length + 3;
|
|
830
|
+
open.lastIndex = at;
|
|
831
|
+
}
|
|
832
|
+
return out + page.slice(at);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, written as a Markdown heading.
|
|
836
|
+
function markHeadings(page) {
|
|
837
|
+
const closes = [[], [], [], [], [], [], []];
|
|
838
|
+
for (const match of page.matchAll(/<\/h([1-6])>/gi)) closes[match[1]].push(match.index);
|
|
839
|
+
const gts = startsOf(page, />/g);
|
|
840
|
+
const open = /<h([1-6])/gi;
|
|
841
|
+
let out = '';
|
|
842
|
+
let at = 0;
|
|
843
|
+
for (let match; (match = open.exec(page));) {
|
|
844
|
+
const end = firstFrom(gts, match.index + 3);
|
|
845
|
+
if (end < 0) break;
|
|
846
|
+
const level = Number(match[1]);
|
|
847
|
+
const close = firstFrom(closes[level], end + 1);
|
|
848
|
+
if (close < 0) continue;
|
|
849
|
+
out += `${page.slice(at, match.index)}\n\n${'#'.repeat(level)} ${stripTags(page.slice(end + 1, close))}\n\n`;
|
|
850
|
+
at = close + 5;
|
|
851
|
+
open.lastIndex = at;
|
|
852
|
+
}
|
|
853
|
+
return out + page.slice(at);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function linkText(href, inner, baseUrl) {
|
|
857
|
+
const label = stripTags(inner).replace(/\s+/g, ' ').trim();
|
|
858
|
+
if (!label) return ' ';
|
|
859
|
+
let target = href;
|
|
860
|
+
try { target = new URL(href, baseUrl).href; } catch { /* keep as written */ }
|
|
861
|
+
return /^https?:/.test(target) ? ` [${label}](${target}) ` : ` ${label} `;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// /<a\b[^>]*href=["']([^"'#][^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, written as a
|
|
865
|
+
// Markdown link. "[^>]*" is greedy, so the last fitting href before the tag's
|
|
866
|
+
// first ">" is tried first; its value may contain ">" and so end the tag later.
|
|
867
|
+
// Every other href ends at that first ">", so at most two are ever tried.
|
|
868
|
+
function markLinks(page, baseUrl) {
|
|
869
|
+
const closes = startsOf(page, /<\/a>/gi);
|
|
870
|
+
const hrefs = startsOf(page, /href=/gi);
|
|
871
|
+
const gts = startsOf(page, />/g);
|
|
872
|
+
const quotes = startsOf(page, /["']/g);
|
|
873
|
+
const open = /<a\b/gi;
|
|
874
|
+
let out = '';
|
|
875
|
+
let at = 0;
|
|
876
|
+
for (let match; (match = open.exec(page));) {
|
|
877
|
+
const start = match.index;
|
|
878
|
+
const gt = firstFrom(gts, start + 2);
|
|
879
|
+
const end = gt < 0 ? page.length : gt;
|
|
880
|
+
let found = null;
|
|
881
|
+
for (let index = indexFrom(hrefs, end) - 1; index >= 0 && hrefs[index] >= start + 2; index -= 1) {
|
|
882
|
+
const href = hrefs[index];
|
|
883
|
+
if (!`"'`.includes(page[href + 5] || 'x') || `"'#`.includes(page[href + 6] ?? '"')) continue;
|
|
884
|
+
const quote = firstFrom(quotes, href + 7);
|
|
885
|
+
if (quote < 0) continue;
|
|
886
|
+
const tagEnd = firstFrom(gts, quote + 1);
|
|
887
|
+
if (tagEnd < 0) continue;
|
|
888
|
+
const close = firstFrom(closes, tagEnd + 1);
|
|
889
|
+
if (close >= 0) { found = { href: page.slice(href + 6, quote), tagEnd, close }; break; }
|
|
890
|
+
if (tagEnd === gt) break;
|
|
891
|
+
}
|
|
892
|
+
if (!found) {
|
|
893
|
+
if (gt < 0) break;
|
|
894
|
+
open.lastIndex = gt;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
out += page.slice(at, start) + linkText(found.href, page.slice(found.tagEnd + 1, found.close), baseUrl);
|
|
898
|
+
at = found.close + 4;
|
|
899
|
+
open.lastIndex = at;
|
|
900
|
+
}
|
|
901
|
+
return out + page.slice(at);
|
|
902
|
+
}
|
|
903
|
+
|
|
695
904
|
// Turns a web page into the text a reader would see, with headings and links
|
|
696
905
|
// kept, so a station can quote and cite it.
|
|
697
906
|
export function htmlToText(html, baseUrl, limit = 14000) {
|
|
698
907
|
let page = String(html || '');
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if (!label) return ' ';
|
|
709
|
-
let target = href;
|
|
710
|
-
try { target = new URL(href, baseUrl).href; } catch { /* keep as written */ }
|
|
711
|
-
return /^https?:/.test(target) ? ` [${label}](${target}) ` : ` ${label} `;
|
|
712
|
-
})
|
|
713
|
-
.replace(/<li[^>]*>/gi, '\n- ')
|
|
714
|
-
.replace(/<(br|\/p|\/div|\/tr|\/section|\/article|\/header|\/footer|\/li|\/ul|\/ol|\/table)\b[^>]*>/gi, '\n')
|
|
715
|
-
.replace(/<\/t[dh]>/gi, ' | ')
|
|
716
|
-
.replace(/<[^>]+>/g, ' ');
|
|
717
|
-
page = decodeEntities(page).replace(/[ \t\f\v ]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
908
|
+
const cut = page.length > PAGE_CHARS;
|
|
909
|
+
if (cut) page = page.slice(0, PAGE_CHARS);
|
|
910
|
+
const title = decodeEntities(pageTitle(page).trim());
|
|
911
|
+
const description = decodeEntities(pageDescription(page));
|
|
912
|
+
page = markLinks(markHeadings(dropRawText(dropComments(page))), baseUrl);
|
|
913
|
+
page = beforeLastGt(page, /<li[^>]*>/gi, '\n- ');
|
|
914
|
+
page = beforeLastGt(page, /<(br|\/p|\/div|\/tr|\/section|\/article|\/header|\/footer|\/li|\/ul|\/ol|\/table)\b[^>]*>/gi, '\n');
|
|
915
|
+
page = stripTags(page.replace(/<\/t[dh]>/gi, ' | '));
|
|
916
|
+
page = decodeEntities(page).replace(/[ \t\f\v\u00a0]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
718
917
|
const head = [title && `Title: ${title}`, description && `Description: ${decodeEntities(description)}`].filter(Boolean).join('\n');
|
|
719
918
|
const text = `${head}\n\n${page}`.trim();
|
|
720
|
-
|
|
919
|
+
if (text.length > limit) return `${text.slice(0, limit)}\n\n[Page cut here: ${text.length - limit} more characters not shown]`;
|
|
920
|
+
return cut ? `${text}\n\n[Page cut here: only the first ${PAGE_CHARS.toLocaleString('en-US')} characters of the page were read]` : text;
|
|
721
921
|
}
|
|
722
922
|
|
|
723
923
|
export function normalizeUrl(value) {
|
|
@@ -813,16 +1013,24 @@ function providerUsage(payload, anthropic) {
|
|
|
813
1013
|
}
|
|
814
1014
|
|
|
815
1015
|
// Anthropic Messages API. Messages stay in this block format inside the engine.
|
|
816
|
-
export
|
|
1016
|
+
export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
1017
|
+
// Current Claude models think before they answer, and thinking counts toward
|
|
1018
|
+
// max_tokens, so every call leaves this much room beyond the step's own budget.
|
|
1019
|
+
export const ANTHROPIC_MAX_OUTPUT = 32000;
|
|
1020
|
+
|
|
1021
|
+
export function anthropicProvider({ apiKey, model, workspaceId, fetchImpl = fetch, browser = false, baseUrl = 'https://api.anthropic.com', timeoutMs = MODEL_TIMEOUT_MS, effort = null, maxOutput = ANTHROPIC_MAX_OUTPUT }) {
|
|
1022
|
+
if (effort != null && !EFFORTS.includes(effort)) throw new Error(`Unknown effort "${effort}". Use one of: ${EFFORTS.join(', ')}.`);
|
|
817
1023
|
return {
|
|
818
1024
|
name: 'anthropic',
|
|
819
1025
|
billing: baseUrl.replace(/\/$/, '') === 'https://api.anthropic.com' ? 'anthropic-direct' : null,
|
|
820
1026
|
model,
|
|
1027
|
+
effort,
|
|
821
1028
|
serverSearch: true,
|
|
822
1029
|
async call({ system, messages, tools = [], search = 0, forceTool, maxTokens = 4096 }) {
|
|
823
1030
|
const toolList = tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema }));
|
|
824
1031
|
if (search > 0) toolList.push({ type: 'web_search_20250305', name: 'web_search', max_uses: search });
|
|
825
|
-
const body = { model, max_tokens: maxTokens, system, messages };
|
|
1032
|
+
const body = { model, max_tokens: Math.max(maxTokens, maxOutput || 0), system, messages };
|
|
1033
|
+
if (effort) body.output_config = { effort };
|
|
826
1034
|
if (toolList.length) body.tools = toolList;
|
|
827
1035
|
if (forceTool) body.tool_choice = { type: 'tool', name: forceTool };
|
|
828
1036
|
const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
|
|
@@ -1369,6 +1577,16 @@ async function callModel(state, host, request, provider = host.provider) {
|
|
|
1369
1577
|
return response;
|
|
1370
1578
|
}
|
|
1371
1579
|
|
|
1580
|
+
// How many pages one model turn reads at the same time.
|
|
1581
|
+
const PAGES_AT_ONCE = 4;
|
|
1582
|
+
|
|
1583
|
+
// Runs `work` on every item, at most `limit` at a time. `work` must not throw.
|
|
1584
|
+
async function eachLimited(items, limit, work) {
|
|
1585
|
+
let next = 0;
|
|
1586
|
+
const lane = async () => { while (next < items.length) await work(items[next++]); };
|
|
1587
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1372
1590
|
async function doWork(state, step, host, record) {
|
|
1373
1591
|
const feedback = state.steps[step.id].feedback;
|
|
1374
1592
|
const context = resolveContext(state, step);
|
|
@@ -1382,9 +1600,16 @@ async function doWork(state, step, host, record) {
|
|
|
1382
1600
|
}
|
|
1383
1601
|
const messages = [{ role: 'user', content: [{ type: 'text', text: parts.filter(Boolean).join('\n\n') }] }];
|
|
1384
1602
|
const tools = step.tools.includes('web_fetch') ? [FETCH_TOOL] : [];
|
|
1603
|
+
// Experimental tool sources (opt-in, tool-sources.mjs): only a run admitted
|
|
1604
|
+
// with them has steps with `sources`, checked against their pinned digests.
|
|
1605
|
+
if (step.sources?.length) {
|
|
1606
|
+
if (!host.toolSources) throw failure('tools', `The step "${step.title}" needs tool sources that this runner was not given.`);
|
|
1607
|
+
await host.toolSources.verify(step);
|
|
1608
|
+
tools.push(...host.toolSources.toolsFor(step));
|
|
1609
|
+
}
|
|
1385
1610
|
const search = step.tools.includes('web_search') && host.provider.serverSearch ? host.limits.searchesPerStep : 0;
|
|
1386
1611
|
if (step.tools.includes('web_search') && !host.provider.serverSearch) record.limitations.push('Web search was unavailable on this runner.');
|
|
1387
|
-
const system = agentSystem(state, step, host);
|
|
1612
|
+
const system = [agentSystem(state, step, host), step.sources?.length ? host.toolSources.instructions(step) : ''].filter(Boolean).join('\n\n');
|
|
1388
1613
|
const maxTokens = step.kind === 'deliver' ? 8000 : 5000;
|
|
1389
1614
|
|
|
1390
1615
|
let searched = 0;
|
|
@@ -1398,37 +1623,52 @@ async function doWork(state, step, host, record) {
|
|
|
1398
1623
|
await host.emit({ type: 'tool', step: step.id, tool: 'web_search', target: query });
|
|
1399
1624
|
}
|
|
1400
1625
|
if (last && (response.stop === 'pause_turn' || response.toolCalls.length)) throw new Error('The model did not finish within this step’s turn limit.');
|
|
1401
|
-
if (response.stop !== 'pause_turn' && !response.toolCalls.length)
|
|
1626
|
+
if (response.stop !== 'pause_turn' && !response.toolCalls.length) {
|
|
1627
|
+
// A tool whose definition changed while the step ran stops it before its result is accepted.
|
|
1628
|
+
if (step.sources?.length) await host.toolSources.verify(step);
|
|
1629
|
+
return finalText(response.content);
|
|
1630
|
+
}
|
|
1402
1631
|
messages.push({ role: 'assistant', content: response.content });
|
|
1403
1632
|
if (response.stop === 'pause_turn') continue;
|
|
1404
|
-
|
|
1633
|
+
// The pages a turn asks for are counted against the budget in the order
|
|
1634
|
+
// asked, then read at the same time, at most four at once. Their results,
|
|
1635
|
+
// tools and limitations are recorded in the order asked.
|
|
1636
|
+
const asked = [];
|
|
1405
1637
|
for (const call of response.toolCalls) {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1638
|
+
if (step.sources?.length && host.toolSources.offers(step, call.name)) {
|
|
1639
|
+
// A tool source is called in its turn below: it writes its own record.
|
|
1640
|
+
asked.push({ call, source: true });
|
|
1641
|
+
} else if (call.name !== 'web_fetch') {
|
|
1642
|
+
asked.push({ call, output: 'That tool does not exist in this factory.' });
|
|
1409
1643
|
} else if (state.usage.fetches >= host.limits.fetches) {
|
|
1410
|
-
output = 'The page budget for this run is used up. Work with what you have.';
|
|
1411
|
-
|
|
1644
|
+
const output = 'The page budget for this run is used up. Work with what you have.';
|
|
1645
|
+
asked.push({ call, output, limitation: output });
|
|
1412
1646
|
} else {
|
|
1413
1647
|
const url = normalizeUrl(call.input?.url);
|
|
1414
1648
|
state.usage.fetches += 1;
|
|
1415
1649
|
await host.emit({ type: 'tool', step: step.id, tool: 'web_fetch', target: url || String(call.input?.url || '') });
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1650
|
+
asked.push(url ? { call, url } : { call, output: 'That is not a public web address.', limitation: 'A requested page address was invalid.' });
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
await eachLimited(asked.filter((item) => item.url), PAGES_AT_ONCE, async (item) => {
|
|
1654
|
+
try {
|
|
1655
|
+
item.output = await host.fetchPage(item.url);
|
|
1656
|
+
item.ok = true;
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
item.output = `The page could not be read: ${error.message}`;
|
|
1659
|
+
item.ok = false;
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
const results = [];
|
|
1663
|
+
for (const item of asked) {
|
|
1664
|
+
if (item.source) item.output = await host.toolSources.call(host, step, item.call, record);
|
|
1665
|
+
if (item.limitation) record.limitations.push(item.limitation);
|
|
1666
|
+
if (item.url) {
|
|
1667
|
+
if (!item.ok) record.limitations.push(`The page ${item.url} could not be read.`);
|
|
1668
|
+
else if (/\[Page cut here:/.test(item.output)) record.limitations.push(`Only part of ${item.url} was available to read.`);
|
|
1669
|
+
record.tools.push({ tool: 'web_fetch', target: item.url, ok: item.ok });
|
|
1430
1670
|
}
|
|
1431
|
-
results.push({ type: 'tool_result', tool_use_id: call.id, content: output });
|
|
1671
|
+
results.push({ type: 'tool_result', tool_use_id: item.call.id, content: item.output });
|
|
1432
1672
|
}
|
|
1433
1673
|
messages.push({ role: 'user', content: results });
|
|
1434
1674
|
}
|
|
@@ -1478,13 +1718,14 @@ async function doCheck(state, step, host, record) {
|
|
|
1478
1718
|
} catch { return null; }
|
|
1479
1719
|
};
|
|
1480
1720
|
const messages = [{ role: 'user', content: [{ type: 'text', text }] }];
|
|
1481
|
-
|
|
1721
|
+
// A reviewer that must quote the result and its source side by side needs room for both.
|
|
1722
|
+
const request = { system, messages, tools: [VERDICT_TOOL], forceTool: 'verdict', maxTokens: 4000 };
|
|
1482
1723
|
record.review.context_sha256 = await sha256(canonical(request));
|
|
1483
1724
|
const first = await callModel(state, host, request, reviewer);
|
|
1484
1725
|
let verdict = read(first);
|
|
1485
1726
|
if (!verdict) {
|
|
1486
1727
|
// A reviewer that wrote prose instead of a verdict is asked once more.
|
|
1487
|
-
const retryRequest = { system, messages: [...messages, { role: 'assistant', content: [{ type: 'text', text: finalText(first.content) || 'I have reviewed the result.' }] }, { role: 'user', content: [{ type: 'text', text: 'Now give your verdict as JSON only, with no other text: {"pass": true or false, "issues": ["one sentence per clear breach"], "note": "one sentence"}' }] }], maxTokens:
|
|
1728
|
+
const retryRequest = { system, messages: [...messages, { role: 'assistant', content: [{ type: 'text', text: finalText(first.content) || 'I have reviewed the result.' }] }, { role: 'user', content: [{ type: 'text', text: 'Now give your verdict as JSON only, with no other text: {"pass": true or false, "issues": ["one sentence per clear breach"], "note": "one sentence"}' }] }], maxTokens: 2500 };
|
|
1488
1729
|
record.review.context_sha256 = await sha256(canonical(retryRequest));
|
|
1489
1730
|
const again = await callModel(state, host, retryRequest, reviewer);
|
|
1490
1731
|
verdict = read(again);
|
|
@@ -1542,33 +1783,45 @@ async function runStep(state, step, host) {
|
|
|
1542
1783
|
return outcome;
|
|
1543
1784
|
}
|
|
1544
1785
|
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1548
|
-
if (state.status !== 'running') return
|
|
1786
|
+
// The checks made before any step starts. Returns false when the run cannot
|
|
1787
|
+
// go on: it was not running, or it has just been failed.
|
|
1788
|
+
async function admit(state, host) {
|
|
1789
|
+
if (state.status !== 'running') return false;
|
|
1549
1790
|
// Re-read preserved source even for restored runs parsed by an older engine.
|
|
1550
1791
|
// UI admission and a cached runnable flag are not execution boundaries.
|
|
1551
|
-
|
|
1552
|
-
|
|
1792
|
+
// A run admitted with experimental tool sources reads its flow as it was admitted.
|
|
1793
|
+
const source = parseBundle(state.factory.files, state.experimental?.includes('tool-sources') ? { features: ['tool-sources'] } : undefined);
|
|
1794
|
+
if (!source.ok) { await fail(state, host, null, source.errors.join(' '), { code: 'invalid_factory' }); return false; }
|
|
1553
1795
|
if (!state.started) {
|
|
1554
1796
|
state.started = host.now();
|
|
1555
1797
|
await host.emit({ type: 'run_started', factory: state.factory.name, steps: state.factory.steps.length });
|
|
1556
1798
|
const checked = checkInputs(state.factory, state.inputs);
|
|
1557
|
-
if (!checked.ok)
|
|
1799
|
+
if (!checked.ok) { await fail(state, host, null, checked.problems.join(' '), { code: 'inputs' }); return false; }
|
|
1558
1800
|
state.inputs = checked.inputs;
|
|
1559
1801
|
// Checked again here, whoever started the run: a factory that asks for a
|
|
1560
1802
|
// tool this runner lacks is not run at all, since without it the run
|
|
1561
1803
|
// would be a different factory from the one its author wrote.
|
|
1562
1804
|
const lacking = state.factory.steps.find((step) => step.missing?.length);
|
|
1563
|
-
if (lacking)
|
|
1564
|
-
}
|
|
1565
|
-
if (source.factory.unsupportedRequirements.length) return fail(state, host, null, source.factory.blockers.join(' '), { code: 'requirements' });
|
|
1566
|
-
const { steps } = state.factory;
|
|
1567
|
-
const ready = steps.filter((step) => state.steps[step.id].status === 'pending' && step.needs.every((need) => state.steps[need].status === 'done'));
|
|
1568
|
-
if (!ready.length) {
|
|
1569
|
-
if (steps.every((step) => state.steps[step.id].status === 'done')) return finish(state, host);
|
|
1570
|
-
return fail(state, host, null, 'The flow cannot continue: a step is waiting on work that will never finish.', { code: 'flow' });
|
|
1805
|
+
if (lacking) { await fail(state, host, lacking.id, `The step "${lacking.title}" needs ${lacking.missing.join(' and ')}, which this runner does not have. Nothing was run.`, { code: 'tools' }); return false; }
|
|
1571
1806
|
}
|
|
1807
|
+
if (source.factory.unsupportedRequirements.length) { await fail(state, host, null, source.factory.blockers.join(' '), { code: 'requirements' }); return false; }
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const readySteps = (state) => state.factory.steps.filter((step) => state.steps[step.id].status === 'pending' && step.needs.every((need) => state.steps[need].status === 'done'));
|
|
1812
|
+
|
|
1813
|
+
// With nothing ready the run is either finished or stuck.
|
|
1814
|
+
function finishOrStuck(state, host) {
|
|
1815
|
+
if (state.factory.steps.every((step) => state.steps[step.id].status === 'done')) return finish(state, host);
|
|
1816
|
+
return fail(state, host, null, 'The flow cannot continue: a step is waiting on work that will never finish.', { code: 'flow' });
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Moves the factory forward by one round: every step that is ready runs, in
|
|
1820
|
+
// parallel. `host` supplies: provider, fetchPage(url), emit(event), now(), limits.
|
|
1821
|
+
export async function advance(state, host) {
|
|
1822
|
+
if (!(await admit(state, host))) return state;
|
|
1823
|
+
const ready = readySteps(state);
|
|
1824
|
+
if (!ready.length) return finishOrStuck(state, host);
|
|
1572
1825
|
|
|
1573
1826
|
const working = ready.filter((step) => step.kind !== 'approve').slice(0, host.limits.parallel || 4);
|
|
1574
1827
|
if (working.length) {
|
|
@@ -1580,8 +1833,11 @@ export async function advance(state, host) {
|
|
|
1580
1833
|
for (const outcome of outcomes) if (outcome.sendBack) { state.steps[outcome.step.id].status = 'pending'; sendBack(state, outcome.sendBack.target, outcome.sendBack); }
|
|
1581
1834
|
return state;
|
|
1582
1835
|
}
|
|
1836
|
+
return approvalStep(state, host, ready[0]);
|
|
1837
|
+
}
|
|
1583
1838
|
|
|
1584
|
-
|
|
1839
|
+
// An approval step: asks the person, or applies the decision they gave.
|
|
1840
|
+
async function approvalStep(state, host, step) {
|
|
1585
1841
|
const slot = state.steps[step.id];
|
|
1586
1842
|
const decision = state.approval && state.approval.step === step.id ? state.approval.decision : null;
|
|
1587
1843
|
if (!decision) {
|
|
@@ -1693,6 +1949,8 @@ export async function buildReceipt(state) {
|
|
|
1693
1949
|
const flags = current.filter((record) => record.status === 'flagged' || record.status === 'unreviewed').map((record) => `${record.title}: ${record.status === 'flagged' ? 'the check still failed after the allowed redo' : 'the reviewer returned no valid verdict'}`);
|
|
1694
1950
|
flags.push(...new Set(current.flatMap((record) => [...(record.limitations || []), ...(record.review?.limitations || [])])));
|
|
1695
1951
|
if (reviewStatus === 'not_checked') flags.push('The final deliverable was not checked.');
|
|
1952
|
+
// Experimental features in use: named by the run, or a step admitted with tool sources.
|
|
1953
|
+
const experimental = [...new Set([...(state.experimental || []), ...(state.factory.steps.some((step) => step.sources?.length) ? ['tool-sources'] : [])])];
|
|
1696
1954
|
return {
|
|
1697
1955
|
v: 2,
|
|
1698
1956
|
kind: 'factory-run-receipt',
|
|
@@ -1710,7 +1968,8 @@ export async function buildReceipt(state) {
|
|
|
1710
1968
|
kind: record.kind,
|
|
1711
1969
|
attempt: record.attempt,
|
|
1712
1970
|
status: record.status,
|
|
1713
|
-
|
|
1971
|
+
// A run with experimental features keeps every call's details, `ok` included.
|
|
1972
|
+
tools: record.tools.map((tool) => (experimental.length ? { ...tool } : { tool: tool.tool, target: tool.target })),
|
|
1714
1973
|
note: record.verdict?.note,
|
|
1715
1974
|
verdict: record.verdict,
|
|
1716
1975
|
review: record.review,
|
|
@@ -1727,9 +1986,96 @@ export async function buildReceipt(state) {
|
|
|
1727
1986
|
deliverable_review: { status: reviewStatus, sha256: deliveredHash, checks: outputs ? outputs.flatMap((output) => output.review.checks) : checks.map((record) => ({ id: record.id, attempt: record.attempt })) },
|
|
1728
1987
|
deliverable_sha256: deliveredHash,
|
|
1729
1988
|
usage: { ...state.usage },
|
|
1989
|
+
...(experimental.length ? { experimental, ...(Array.isArray(state.toolSourceRecords) ? { tool_sources: state.toolSourceRecords } : {}) } : {}),
|
|
1730
1990
|
};
|
|
1731
1991
|
}
|
|
1732
1992
|
|
|
1993
|
+
// How many rounds runToEnd allows before it gives up, and so how many step
|
|
1994
|
+
// starts: a round starts at most `limits.parallel` steps.
|
|
1995
|
+
const MAX_ROUNDS = 120;
|
|
1996
|
+
|
|
1997
|
+
// Applies the result of a step that ran without error. A check that failed its
|
|
1998
|
+
// subject sends the subject back. A step that was itself sent back while it
|
|
1999
|
+
// ran stays pending: it was built on work that is being redone.
|
|
2000
|
+
function settle(state, outcome) {
|
|
2001
|
+
const slot = state.steps[outcome.step.id];
|
|
2002
|
+
if (outcome.sendBack) { slot.status = 'pending'; sendBack(state, outcome.sendBack.target, outcome.sendBack); }
|
|
2003
|
+
else if (slot.status === 'running') slot.status = 'done';
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// Whether a ready step waits for steps already in flight. Work that was sent
|
|
2007
|
+
// back waits until the runs built on it have ended, so every reviewer's
|
|
2008
|
+
// reasons reach the same redo. A step built on work under review waits for
|
|
2009
|
+
// that review, so it never runs on a result that is about to be sent back.
|
|
2010
|
+
// Steps that become ready together start together, as in a round.
|
|
2011
|
+
function mustWait(state, step, inFlight) {
|
|
2012
|
+
if (!inFlight.length) return false;
|
|
2013
|
+
const ancestors = ancestorsOf(state.factory, step.id);
|
|
2014
|
+
return inFlight.some((id) => {
|
|
2015
|
+
const other = state.factory.steps.find((item) => item.id === id);
|
|
2016
|
+
return ancestorsOf(state.factory, id).includes(step.id) || (other.kind === 'check' && ancestors.includes(other.target));
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// Runs the factory by dataflow: a step starts as soon as its own needs are
|
|
2021
|
+
// done, up to `limits.parallel` at once, instead of when the slowest step of
|
|
2022
|
+
// its round ends. Returns when the run ends or only an approval is left: the
|
|
2023
|
+
// person is asked only when nothing is in flight. advance() keeps the rounds.
|
|
2024
|
+
async function runReady(state, host, budget) {
|
|
2025
|
+
if (!(await admit(state, host))) return state;
|
|
2026
|
+
const limit = host.limits.parallel || 4;
|
|
2027
|
+
const running = new Map();
|
|
2028
|
+
// Records keep the order their steps started in, whatever order the steps
|
|
2029
|
+
// end in: each record's place is reserved by its start number. A record is
|
|
2030
|
+
// in state.records as soon as its step ends, because later steps read it.
|
|
2031
|
+
const from = state.records.length;
|
|
2032
|
+
const places = [];
|
|
2033
|
+
let failed = null;
|
|
2034
|
+
let exhausted = false;
|
|
2035
|
+
let held = [];
|
|
2036
|
+
for (;;) {
|
|
2037
|
+
if (!failed) {
|
|
2038
|
+
const inFlight = [...running.keys()];
|
|
2039
|
+
for (const step of readySteps(state)) {
|
|
2040
|
+
if (running.size >= limit) break;
|
|
2041
|
+
if (step.kind === 'approve' || running.has(step.id) || mustWait(state, step, inFlight)) continue;
|
|
2042
|
+
if (budget.starts >= MAX_ROUNDS * limit) { exhausted = true; break; }
|
|
2043
|
+
const place = budget.starts;
|
|
2044
|
+
budget.starts += 1;
|
|
2045
|
+
running.set(step.id, runStep(state, step, host).then((outcome) => ({ outcome, place })));
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
if (!running.size) break;
|
|
2049
|
+
const { outcome, place } = await Promise.race(running.values());
|
|
2050
|
+
running.delete(outcome.step.id);
|
|
2051
|
+
let at = places.length;
|
|
2052
|
+
while (at > 0 && places[at - 1] > place) at -= 1;
|
|
2053
|
+
places.splice(at, 0, place);
|
|
2054
|
+
state.records.splice(from + at, 0, outcome.record);
|
|
2055
|
+
// After a failure nothing new starts; the steps in flight end first.
|
|
2056
|
+
if (outcome.error) failed ||= outcome;
|
|
2057
|
+
else if (!failed) held.push(outcome);
|
|
2058
|
+
if (failed) continue;
|
|
2059
|
+
// Reviewers of the same work end at different moments. A review that
|
|
2060
|
+
// sends the work back waits for the others still reviewing it, and they
|
|
2061
|
+
// are applied in the order the flow lists them: the redo then reads their
|
|
2062
|
+
// reasons in the same order on every run, as it does in a round.
|
|
2063
|
+
const reviewed = new Set([...running.keys()].map((id) => state.factory.steps.find((item) => item.id === id)).filter((item) => item.kind === 'check').map((item) => item.target));
|
|
2064
|
+
const due = held.filter((item) => !(item.sendBack && reviewed.has(item.sendBack.target)));
|
|
2065
|
+
held = held.filter((item) => !due.includes(item));
|
|
2066
|
+
for (const item of due.sort((a, b) => state.factory.steps.indexOf(a.step) - state.factory.steps.indexOf(b.step))) settle(state, item);
|
|
2067
|
+
}
|
|
2068
|
+
if (failed) return fail(state, host, failed.step.id, failed.error, failed.failure);
|
|
2069
|
+
if (exhausted) {
|
|
2070
|
+
state.status = 'failed';
|
|
2071
|
+
state.error = 'The factory did not finish within its step budget.';
|
|
2072
|
+
return state;
|
|
2073
|
+
}
|
|
2074
|
+
const ready = readySteps(state);
|
|
2075
|
+
if (!ready.length) return finishOrStuck(state, host);
|
|
2076
|
+
return approvalStep(state, host, ready[0]);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1733
2079
|
// Runs a factory until it ends or needs a person. `onApproval(approval,
|
|
1734
2080
|
// artifactText, problem)` resolves to { action: 'approve' | 'revise' |
|
|
1735
2081
|
// 'reject', note?, seen, approvalId } where approvalId is approval.id and
|
|
@@ -1739,8 +2085,9 @@ export async function buildReceipt(state) {
|
|
|
1739
2085
|
// onApproval, or an answer that is not a decision, the run is returned still
|
|
1740
2086
|
// waiting. A refused decision is asked again with the reason.
|
|
1741
2087
|
export async function runToEnd(state, host, onApproval) {
|
|
1742
|
-
|
|
1743
|
-
|
|
2088
|
+
const budget = { starts: 0 };
|
|
2089
|
+
for (let guard = 0; guard < MAX_ROUNDS; guard += 1) {
|
|
2090
|
+
await runReady(state, host, budget);
|
|
1744
2091
|
if (state.status === 'waiting') {
|
|
1745
2092
|
await ensureApprovalIdentity(state);
|
|
1746
2093
|
if (!onApproval) return state;
|