@sema-agent/core 5.10.0 → 5.12.0

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 (40) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/agents/subagent.js +6 -2
  4. package/dist/brain/anthropic.js +1 -1
  5. package/dist/brain/openai.js +1 -1
  6. package/dist/core/auto-compaction.d.ts +12 -1
  7. package/dist/core/auto-compaction.js +3 -1
  8. package/dist/core/background-agent-store.d.ts +2 -1
  9. package/dist/core/background-agent-store.js +1 -0
  10. package/dist/core/checkpoint-store.d.ts +2 -0
  11. package/dist/core/checkpoint-store.js +1 -0
  12. package/dist/core/exec-gate.js +12 -1
  13. package/dist/core/runner/assemble-result.js +9 -0
  14. package/dist/core/runner/compaction-call-options.d.ts +13 -1
  15. package/dist/core/runner/compaction-call-options.js +85 -0
  16. package/dist/core/runner/prepare-task.d.ts +6 -0
  17. package/dist/core/runner/prepare-task.js +118 -36
  18. package/dist/core/runner/runtask.js +71 -21
  19. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  20. package/dist/core/runner/tool-disclosure.js +24 -9
  21. package/dist/core/runner/turn-attachments.d.ts +2 -0
  22. package/dist/core/runner/turn-attachments.js +17 -6
  23. package/dist/core/task-registry-agent.d.ts +3 -0
  24. package/dist/core/task-registry-agent.js +9 -2
  25. package/dist/core/task-registry-shared.d.ts +1 -0
  26. package/dist/core/task-registry.d.ts +2 -0
  27. package/dist/core/trace.d.ts +1 -0
  28. package/dist/core/types.d.ts +9 -1
  29. package/dist/engine/compaction/compaction.d.ts +11 -2
  30. package/dist/engine/compaction/compaction.js +87 -9
  31. package/dist/index.d.ts +1 -1
  32. package/dist/prompt-assembly/event-registry.js +1 -1
  33. package/dist/prompts/default.d.ts +1 -0
  34. package/dist/prompts/default.js +3 -0
  35. package/dist/tools/fs/fs-bash.js +4 -1
  36. package/dist/tools/fs/index.d.ts +1 -0
  37. package/dist/tools/fs/index.js +7 -4
  38. package/dist/tools/web.d.ts +21 -1
  39. package/dist/tools/web.js +126 -10
  40. package/package.json +1 -1
package/dist/tools/web.js CHANGED
@@ -25,6 +25,25 @@ function resolveWebMaxBytes(value) {
25
25
  }
26
26
  return value;
27
27
  }
28
+ export const WEBFETCH_GROUNDING_MIN_TEXT_CHARS = 200;
29
+ const GROUNDING_MIN_TEXT_RATIO = 0.01;
30
+ const GROUNDING_RATIO_MAX_TEXT_CHARS = 2_000;
31
+ const GROUNDING_ECHO_MAX_CHARS = GROUNDING_RATIO_MAX_TEXT_CHARS + 100;
32
+ const GROUNDING_SHELL_MIN_BYTES = 500;
33
+ function assessGrounding(text, bytes) {
34
+ const textChars = text.replace(/\s+/g, " ").trim().length;
35
+ const textRatio = bytes > 0 ? (textChars / bytes) : 0;
36
+ const low = textChars < WEBFETCH_GROUNDING_MIN_TEXT_CHARS ||
37
+ (textChars < GROUNDING_RATIO_MAX_TEXT_CHARS && bytes > 0 && textRatio < GROUNDING_MIN_TEXT_RATIO);
38
+ return { level: low ? "low" : "ok", textChars, bytes, textRatio: Math.round(textRatio * 1000) / 1000 };
39
+ }
40
+ function recoveryToolRuledOut(ctx, toolName) {
41
+ if (ctx.excludeTools?.includes(toolName) ?? false)
42
+ return true;
43
+ const deferred = ctx.deferTools?.includes(toolName) ?? false;
44
+ const pinnedInline = ctx.alwaysLoadTools?.includes(toolName) ?? false;
45
+ return deferred && !pinnedInline;
46
+ }
28
47
  const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
29
48
  const ERROR_BODY_EXCERPT_CHARS = 2048;
30
49
  const ERROR_BODY_CONVERT_MAX_CHARS = 64 * 1024;
@@ -471,10 +490,14 @@ export function webFetchToolSpec(config = {}) {
471
490
  : bodyCut
472
491
  ? `\n\n[WebFetch: ${cutPhrase} — the ${bodyBytes.length} bytes are a partial prefix received before the cutoff, NOT the object's full size]`
473
492
  : "";
493
+ const shellRecoveryRuledOut = recoveryToolRuledOut(ctx, "Bash") || ctx.handsReadOnly === true;
494
+ const recoveryLine = shellRecoveryRuledOut
495
+ ? "Retrieve it outside this tool — this run has no shell capability that can download it to a file."
496
+ : `Download and inspect it with Bash instead, e.g.: curl -L -o /tmp/download "${parsed.toString()}"`;
474
497
  return {
475
498
  content: `Error (WebFetch): binary content detected (${kind}${mt && signature ? `, content-type: ${mt}` : ""}, ` +
476
499
  `${bodyBytes.length} bytes) — the body was NOT added to the context (it does not decode as text). ` +
477
- `Download and inspect it with Bash instead, e.g.: curl -L -o /tmp/download "${parsed.toString()}"` +
500
+ recoveryLine +
478
501
  binaryStateNote,
479
502
  details: {
480
503
  type: "web-fetch",
@@ -503,8 +526,11 @@ export function webFetchToolSpec(config = {}) {
503
526
  raw = raw.slice(0, maxBytes);
504
527
  }
505
528
  const text = /html/i.test(contentType) || /^\s*</.test(raw) ? htmlToText(raw) : raw;
529
+ const grounding = assessGrounding(text, bodyBytes ? bodyBytes.length : raw.length);
506
530
  let out = text;
507
531
  let summaryTruncated = false;
532
+ let summaryApplied = false;
533
+ let summaryInputNote;
508
534
  let note;
509
535
  if (prompt && bodyCut) {
510
536
  note =
@@ -516,13 +542,24 @@ export function webFetchToolSpec(config = {}) {
516
542
  const summarized = await config.summarize(text, prompt, ctx.signal);
517
543
  if (typeof summarized === "string") {
518
544
  out = summarized;
545
+ summaryApplied = true;
519
546
  }
520
547
  else {
521
548
  out = summarized.text;
549
+ summaryApplied = true;
522
550
  if (summarized.truncated) {
523
551
  summaryTruncated = true;
524
552
  note = `[note: the summary below is INCOMPLETE — the summarizer hit its output limit before finishing]`;
525
553
  }
554
+ if (summarized.inputTruncated) {
555
+ const sizes = summarized.usedChars !== undefined && summarized.inputChars !== undefined
556
+ ? ` (only the first ${summarized.usedChars} of ${summarized.inputChars} characters were read)`
557
+ : "";
558
+ summaryInputNote =
559
+ `[note: the summary below covers only the BEGINNING of the page${sizes} — the page exceeded what the ` +
560
+ `summarizer could feed its model, so the rest was never read. Absence of something from the summary ` +
561
+ `does NOT mean it is absent from the page.]`;
562
+ }
526
563
  }
527
564
  }
528
565
  catch (e) {
@@ -537,12 +574,44 @@ export function webFetchToolSpec(config = {}) {
537
574
  `[note: summarization unavailable in this deployment — raw page content follows; ` +
538
575
  `the requested analysis ("${inlineUntrusted(prompt, 120)}") was NOT applied]`;
539
576
  }
577
+ const bodyIncomplete = truncationNote !== "" || bodyCut !== undefined;
578
+ let groundingNote;
579
+ let sourceEcho = "";
580
+ if (grounding.level === "low") {
581
+ const shellShape = /html/i.test(contentType) && grounding.bytes >= GROUNDING_SHELL_MIN_BYTES
582
+ ? bodyIncomplete
583
+ ? " The retrieved bytes are almost entirely markup/script — which is either a client-rendered shell or " +
584
+ "simply the head of a document whose content sits past the retrieved prefix; a larger byte limit (or a " +
585
+ "completed transfer) would distinguish the two."
586
+ : " The retrieved bytes are almost entirely markup/script, which is the shape of a page whose content is " +
587
+ "loaded by JavaScript after the document — this tool does not run JavaScript, so that content was never present."
588
+ : "";
589
+ const nothingExtractable = grounding.textChars === 0;
590
+ const sourceLabel = bodyIncomplete ? "extracted text of the retrieved prefix" : "complete extracted page text";
591
+ const head = `[WebFetch grounding: LOW — the ${bodyIncomplete ? "retrieved prefix" : "page"} yielded ` +
592
+ (nothingExtractable ? "NO extractable text at all" : `only ${grounding.textChars} characters of extractable text`) +
593
+ ` out of ${grounding.bytes} retrieved bytes`;
594
+ groundingNote = summaryApplied
595
+ ? `${head}. ` +
596
+ (nothingExtractable
597
+ ? `There is no source text at all, so every specific claim in the summary below is unsourced — do not use it as a factual source.`
598
+ : `That may be all this URL serves, or it may be a page whose content is not present in the fetched bytes — this tool ` +
599
+ `cannot tell the two apart, so the summary below is NOT usable as a source on its own: check every specific claim ` +
600
+ `in it against the ${sourceLabel} reproduced after it.`) +
601
+ shellShape +
602
+ `]`
603
+ : `${head}; the content below is ${bodyIncomplete ? "all the retrieved prefix yielded" : "all of it"}.${shellShape}]`;
604
+ if (summaryApplied && !nothingExtractable) {
605
+ sourceEcho = `\n\n${delimitUntrusted(`WebFetch ${parsed.hostname} — ${sourceLabel}`, text.replace(/\s+/g, " ").trim(), GROUNDING_ECHO_MAX_CHARS)}`;
606
+ }
607
+ }
540
608
  const fenced = delimitUntrusted(`WebFetch ${parsed.hostname}`, out);
541
- const withNote = note ? `${note}\n\n${fenced}` : fenced;
609
+ const headNotes = [groundingNote, note, summaryInputNote].filter((n) => n !== undefined);
610
+ const withNote = headNotes.length > 0 ? `${headNotes.join("\n\n")}\n\n${fenced}` : fenced;
542
611
  const partialNote = bodyCut
543
612
  ? `\n\n[WebFetch: ${cutPhrase} — the content above is PARTIAL: ${bodyBytes?.length ?? 0} bytes were received before the cutoff and the tail is missing. Treat absent information as unfetched, not absent from the source.]`
544
613
  : "";
545
- const modelText = withNote + truncationNote + partialNote;
614
+ const modelText = withNote + truncationNote + partialNote + sourceEcho;
546
615
  const RESULT_PREVIEW_CHARS = 8_000;
547
616
  return {
548
617
  content: modelText,
@@ -554,7 +623,9 @@ export function webFetchToolSpec(config = {}) {
554
623
  bytes: bodyBytes ? bodyBytes.length : raw.length,
555
624
  result: out.length > RESULT_PREVIEW_CHARS ? `${out.slice(0, RESULT_PREVIEW_CHARS)}\n…[${out.length - RESULT_PREVIEW_CHARS} chars truncated — full text in the tool output]` : out,
556
625
  durationMs: Date.now() - startedAt,
557
- ...(truncationNote || summaryTruncated ? { truncated: true } : {}),
626
+ ...(truncationNote || summaryTruncated || summaryInputNote ? { truncated: true } : {}),
627
+ grounding,
628
+ ...(summaryInputNote ? { summaryInputTruncated: true } : {}),
558
629
  ...transferStateDetails,
559
630
  },
560
631
  };
@@ -570,12 +641,50 @@ export const WEBFETCH_SUMMARY_GUIDELINES = `Provide a concise response based onl
570
641
  ` - Use quotation marks for exact language from articles; any language outside of the quotation should never be word-for-word the same.\n` +
571
642
  ` - You are not a lawyer and never comment on the legality of your own prompts and responses.\n` +
572
643
  ` - Never produce or reproduce exact song lyrics.\n`;
573
- export function createWebFetchSummarizer(brain, model) {
644
+ export const WEBFETCH_SUMMARY_GROUNDING_CLAUSE = `Ground every statement in the content above:\n` +
645
+ ` - If the content does not contain what was asked for, say exactly that and describe what the content IS instead. Never fill the gap with general knowledge or plausible inference.\n` +
646
+ ` - If the content is a listing (file names, links, titles) rather than the data itself, a name is evidence only about naming — do not conclude from names alone that the underlying resource does or does not contain something; report what the listing shows and what would have to be opened to answer.\n`;
647
+ export const WEBFETCH_SUMMARY_INPUT_HEADROOM = 0.8;
648
+ export const WEBFETCH_SUMMARY_MIN_CONTENT = 4_000;
649
+ const SUMMARY_PROMPT_OVERHEAD_TOKENS = 1_000;
650
+ const SUMMARY_PROMPT_ALLOWANCE_CHARS = 2_000;
651
+ const SUMMARY_MIN_CONTENT_PER_CALL = 1_024;
652
+ function invalidSummaryBudget(message) {
653
+ const e = new Error(message);
654
+ e.code = "config.web_summary_max_content_invalid";
655
+ return e;
656
+ }
657
+ export function resolveSummaryInputChars(model, override) {
658
+ if (override !== undefined) {
659
+ if (!Number.isFinite(override) || override < 1) {
660
+ throw invalidSummaryBudget(`WebFetch summarizer maxContentChars must be a finite number of characters >= 1 (got ${String(override)})`);
661
+ }
662
+ return Math.floor(override);
663
+ }
664
+ const window = model.contextTokens ?? model.contextWindow;
665
+ const charsPerToken = model.charsPerToken ?? 4;
666
+ for (const [name, value] of [
667
+ ["contextTokens/contextWindow", window],
668
+ ["maxTokens", model.maxTokens],
669
+ ["charsPerToken", charsPerToken],
670
+ ]) {
671
+ if (!Number.isFinite(value) || value <= 0) {
672
+ throw invalidSummaryBudget(`WebFetch summarizer cannot size its input: model "${model.id}" declares a non-finite or non-positive ${name} (got ${String(value)})`);
673
+ }
674
+ }
675
+ const reservedOutputTokens = Math.min(model.maxTokens, Math.floor(window / 4));
676
+ const inputTokens = window - reservedOutputTokens - SUMMARY_PROMPT_OVERHEAD_TOKENS;
677
+ const derived = Math.floor(inputTokens * charsPerToken * WEBFETCH_SUMMARY_INPUT_HEADROOM);
678
+ return Math.min(WEBFETCH_SUMMARY_MAX_CONTENT, Math.max(WEBFETCH_SUMMARY_MIN_CONTENT, derived));
679
+ }
680
+ export function createWebFetchSummarizer(brain, model, options = {}) {
681
+ const maxContentChars = resolveSummaryInputChars(model, options.maxContentChars);
574
682
  return async (content, prompt, signal) => {
575
- const truncated = content.length > WEBFETCH_SUMMARY_MAX_CONTENT
576
- ? content.slice(0, WEBFETCH_SUMMARY_MAX_CONTENT) + "\n\n[Content truncated due to length...]"
577
- : content;
578
- const userPrompt = `\nWeb page content:\n---\n${truncated}\n---\n\n${prompt}\n\n` + WEBFETCH_SUMMARY_GUIDELINES;
683
+ const promptOverflow = Math.max(0, prompt.length - SUMMARY_PROMPT_ALLOWANCE_CHARS);
684
+ const budget = promptOverflow === 0 ? maxContentChars : Math.max(SUMMARY_MIN_CONTENT_PER_CALL, maxContentChars - promptOverflow);
685
+ const inputTruncated = content.length > budget;
686
+ const truncated = inputTruncated ? content.slice(0, budget) + "\n\n[Content truncated due to length...]" : content;
687
+ const userPrompt = `\nWeb page content:\n---\n${truncated}\n---\n\n${prompt}\n\n` + WEBFETCH_SUMMARY_GROUNDING_CLAUSE + "\n" + WEBFETCH_SUMMARY_GUIDELINES;
579
688
  const context = { messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] };
580
689
  const msg = brain.complete
581
690
  ? await brain.complete(model, context, { signal })
@@ -590,7 +699,14 @@ export function createWebFetchSummarizer(brain, model) {
590
699
  .trim();
591
700
  if (!text)
592
701
  throw new Error("summarizer returned no text");
593
- return msg.stopReason === "length" || msg.partialFinalized === true ? { text, truncated: true } : text;
702
+ const outputTruncated = msg.stopReason === "length" || msg.partialFinalized === true;
703
+ if (!outputTruncated && !inputTruncated)
704
+ return text;
705
+ return {
706
+ text,
707
+ ...(outputTruncated ? { truncated: true } : {}),
708
+ ...(inputTruncated ? { inputTruncated: true, inputChars: content.length, usedChars: budget } : {}),
709
+ };
594
710
  };
595
711
  }
596
712
  const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.10.0",
3
+ "version": "5.12.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",