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.
Files changed (87) hide show
  1. package/README.md +9 -3
  2. package/factories/index.json +231 -0
  3. package/factories/newsletter-note/README.md +11 -0
  4. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  5. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  6. package/factories/newsletter-note/factory.yaml +12 -0
  7. package/factories/newsletter-note/flow.yaml +45 -0
  8. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  9. package/factories/release-desk/README.md +13 -0
  10. package/factories/release-desk/agents/editor/agent.md +10 -0
  11. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  12. package/factories/release-desk/factory.yaml +13 -0
  13. package/factories/release-desk/flow.yaml +67 -0
  14. package/factories/release-desk/mindburn.tests.yaml +163 -0
  15. package/factories/short-video-cut/README.md +11 -0
  16. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  17. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  18. package/factories/short-video-cut/factory.yaml +13 -0
  19. package/factories/short-video-cut/flow.yaml +46 -0
  20. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  21. package/factories/video-facts/README.md +11 -0
  22. package/factories/video-facts/agents/analyst/agent.md +10 -0
  23. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  24. package/factories/video-facts/factory.yaml +13 -0
  25. package/factories/video-facts/flow.yaml +39 -0
  26. package/factories/video-facts/mindburn.tests.yaml +34 -0
  27. package/factories/video-to-posts/README.md +35 -0
  28. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  29. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  30. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  31. package/factories/video-to-posts/factory.yaml +12 -0
  32. package/factories/video-to-posts/flow.yaml +62 -0
  33. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  34. package/factories/week-of-posts/README.md +11 -0
  35. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  36. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  37. package/factories/week-of-posts/factory.yaml +12 -0
  38. package/factories/week-of-posts/flow.yaml +46 -0
  39. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  40. package/package.json +15 -2
  41. package/packages/cli/package.json +1 -1
  42. package/packages/cli/src/commands/export.mjs +0 -1
  43. package/packages/cli/src/commands/get.mjs +0 -1
  44. package/packages/cli/src/commands/inspect.mjs +0 -1
  45. package/packages/cli/src/commands/list.mjs +1 -6
  46. package/packages/cli/src/commands/login.mjs +161 -0
  47. package/packages/cli/src/commands/logout.mjs +43 -0
  48. package/packages/cli/src/commands/mcp.mjs +0 -1
  49. package/packages/cli/src/commands/new.mjs +0 -1
  50. package/packages/cli/src/commands/pack.mjs +0 -1
  51. package/packages/cli/src/commands/publish.mjs +62 -0
  52. package/packages/cli/src/commands/pull.mjs +59 -0
  53. package/packages/cli/src/commands/push.mjs +115 -0
  54. package/packages/cli/src/commands/resume.mjs +0 -1
  55. package/packages/cli/src/commands/run.mjs +182 -20
  56. package/packages/cli/src/commands/runs.mjs +52 -0
  57. package/packages/cli/src/commands/search.mjs +0 -1
  58. package/packages/cli/src/commands/validate.mjs +1 -3
  59. package/packages/cli/src/commands/whoami.mjs +34 -0
  60. package/packages/cli/src/hosted.mjs +159 -0
  61. package/packages/cli/src/main.mjs +80 -14
  62. package/packages/cli/src/tool-sources.mjs +272 -0
  63. package/packages/cli/src/util.mjs +37 -0
  64. package/packages/engine/lib/credentials.mjs +121 -0
  65. package/packages/engine/lib/hosted.mjs +119 -0
  66. package/packages/engine/lib/hub.mjs +376 -24
  67. package/packages/engine/lib/nango.mjs +157 -0
  68. package/packages/engine/lib/node.mjs +161 -33
  69. package/packages/engine/lib/providers.mjs +8 -3
  70. package/packages/engine/lib/run.mjs +70 -9
  71. package/packages/engine/lib/text.mjs +5 -0
  72. package/packages/engine/package.json +8 -2
  73. package/packages/engine/src/factory-engine.mjs +415 -68
  74. package/packages/engine/src/flow-sources.mjs +135 -0
  75. package/packages/engine/src/mcp-client.mjs +429 -0
  76. package/packages/engine/src/run.mjs +81 -5
  77. package/packages/engine/src/tool-sources.mjs +566 -0
  78. package/packages/mcp/package.json +1 -1
  79. package/packages/mcp/src/server.mjs +47 -17
  80. package/packages/sdk/package.json +28 -0
  81. package/packages/sdk/src/index.d.ts +163 -0
  82. package/packages/sdk/src/index.mjs +266 -0
  83. package/spec/README.md +7 -1
  84. package/spec/factory-format.md +264 -19
  85. package/spec/schema/factory.yaml.json +815 -0
  86. package/spec/schema/flow.schema.json +24 -2
  87. 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
- export function parseBundle(input) {
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 title = decodeEntities((page.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim());
700
- const description = decodeEntities(page.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i)?.[1]
701
- || page.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i)?.[1] || '');
702
- page = page
703
- .replace(/<!--[\s\S]*?-->/g, ' ')
704
- .replace(/<(script|style|noscript|svg|template|iframe|canvas)\b[\s\S]*?<\/\1>/gi, ' ')
705
- .replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, inner) => `\n\n${'#'.repeat(Number(level))} ${inner.replace(/<[^>]+>/g, ' ')}\n\n`)
706
- .replace(/<a\b[^>]*href=["']([^"'#][^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, inner) => {
707
- const label = inner.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
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
- return text.length > limit ? `${text.slice(0, limit)}\n\n[Page cut here: ${text.length - limit} more characters not shown]` : text;
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 function anthropicProvider({ apiKey, model, workspaceId, fetchImpl = fetch, browser = false, baseUrl = 'https://api.anthropic.com', timeoutMs = MODEL_TIMEOUT_MS }) {
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) return finalText(response.content);
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
- const results = [];
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
- let output;
1407
- if (call.name !== 'web_fetch') {
1408
- output = 'That tool does not exist in this factory.';
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
- record.limitations.push(output);
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
- if (!url) {
1417
- output = 'That is not a public web address.';
1418
- record.limitations.push('A requested page address was invalid.');
1419
- } else {
1420
- try {
1421
- output = await host.fetchPage(url);
1422
- if (/\[Page cut here:/.test(output)) record.limitations.push(`Only part of ${url} was available to read.`);
1423
- record.tools.push({ tool: 'web_fetch', target: url, ok: true });
1424
- } catch (error) {
1425
- output = `The page could not be read: ${error.message}`;
1426
- record.limitations.push(`The page ${url} could not be read.`);
1427
- record.tools.push({ tool: 'web_fetch', target: url, ok: false });
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
- const request = { system, messages, tools: [VERDICT_TOOL], forceTool: 'verdict', maxTokens: 1500 };
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: 1000 };
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
- // Moves the factory forward by one round: every step that is ready runs, in
1546
- // parallel. `host` supplies: provider, fetchPage(url), emit(event), now(), limits.
1547
- export async function advance(state, host) {
1548
- if (state.status !== 'running') return state;
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
- const source = parseBundle(state.factory.files);
1552
- if (!source.ok) return fail(state, host, null, source.errors.join(' '), { code: 'invalid_factory' });
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) return fail(state, host, null, checked.problems.join(' '), { code: 'inputs' });
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) return 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' });
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
- const step = ready[0];
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
- tools: record.tools.map((tool) => ({ tool: tool.tool, target: tool.target })),
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
- for (let guard = 0; guard < 120; guard += 1) {
1743
- await advance(state, host);
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;