@qvac/harness 0.7.3 → 0.8.5

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 (146) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/NOTICE.md +16 -0
  3. package/dist-lib/lib/capabilities/completion.d.ts +1 -0
  4. package/dist-lib/lib/capabilities/completion.js +28 -8
  5. package/dist-lib/lib/capabilities/tool-turns.d.ts +3 -0
  6. package/dist-lib/lib/capabilities/tool-turns.js +7 -0
  7. package/dist-lib/lib/context-window.js +2 -2
  8. package/dist-lib/lib/docx/index.d.ts +1 -1
  9. package/dist-lib/lib/docx/index.js +16 -24
  10. package/dist-lib/lib/helpers/run-outcome.d.ts +4 -1
  11. package/dist-lib/lib/helpers/run-outcome.js +14 -8
  12. package/dist-lib/lib/helpers/tool-call-markup.js +4 -4
  13. package/dist-lib/lib/hooks/built-in/arg-repair.js +7 -0
  14. package/dist-lib/lib/office/engine/codes.d.ts +62 -0
  15. package/dist-lib/lib/office/engine/codes.js +61 -0
  16. package/dist-lib/lib/office/engine/docx/document.d.ts +216 -0
  17. package/dist-lib/lib/office/engine/docx/document.js +1152 -0
  18. package/dist-lib/lib/office/engine/docx/index.d.ts +7 -0
  19. package/dist-lib/lib/office/engine/docx/index.js +8 -0
  20. package/dist-lib/lib/office/engine/docx/styles.d.ts +31 -0
  21. package/dist-lib/lib/office/engine/docx/styles.js +173 -0
  22. package/dist-lib/lib/office/engine/docx/template.d.ts +18 -0
  23. package/dist-lib/lib/office/engine/docx/template.js +133 -0
  24. package/dist-lib/lib/office/engine/docx/text.d.ts +55 -0
  25. package/dist-lib/lib/office/engine/docx/text.js +538 -0
  26. package/dist-lib/lib/office/engine/drawingml/chart.d.ts +63 -0
  27. package/dist-lib/lib/office/engine/drawingml/chart.js +539 -0
  28. package/dist-lib/lib/office/engine/drawingml/text.d.ts +35 -0
  29. package/dist-lib/lib/office/engine/drawingml/text.js +352 -0
  30. package/dist-lib/lib/office/engine/errors.d.ts +6 -0
  31. package/dist-lib/lib/office/engine/errors.js +12 -0
  32. package/dist-lib/lib/office/engine/image.d.ts +8 -0
  33. package/dist-lib/lib/office/engine/image.js +48 -0
  34. package/dist-lib/lib/office/engine/index.d.ts +12 -0
  35. package/dist-lib/lib/office/engine/index.js +7 -0
  36. package/dist-lib/lib/office/engine/opc/content-types.d.ts +17 -0
  37. package/dist-lib/lib/office/engine/opc/content-types.js +74 -0
  38. package/dist-lib/lib/office/engine/opc/media.d.ts +13 -0
  39. package/dist-lib/lib/office/engine/opc/media.js +11 -0
  40. package/dist-lib/lib/office/engine/opc/package.d.ts +51 -0
  41. package/dist-lib/lib/office/engine/opc/package.js +324 -0
  42. package/dist-lib/lib/office/engine/opc/paths.d.ts +7 -0
  43. package/dist-lib/lib/office/engine/opc/paths.js +46 -0
  44. package/dist-lib/lib/office/engine/opc/properties.d.ts +7 -0
  45. package/dist-lib/lib/office/engine/opc/properties.js +75 -0
  46. package/dist-lib/lib/office/engine/opc/relationships.d.ts +23 -0
  47. package/dist-lib/lib/office/engine/opc/relationships.js +65 -0
  48. package/dist-lib/lib/office/engine/opc/zip.d.ts +15 -0
  49. package/dist-lib/lib/office/engine/opc/zip.js +264 -0
  50. package/dist-lib/lib/office/engine/pptx/fit.d.ts +12 -0
  51. package/dist-lib/lib/office/engine/pptx/fit.js +37 -0
  52. package/dist-lib/lib/office/engine/pptx/index.d.ts +8 -0
  53. package/dist-lib/lib/office/engine/pptx/index.js +8 -0
  54. package/dist-lib/lib/office/engine/pptx/inherit.d.ts +29 -0
  55. package/dist-lib/lib/office/engine/pptx/inherit.js +155 -0
  56. package/dist-lib/lib/office/engine/pptx/notes.d.ts +5 -0
  57. package/dist-lib/lib/office/engine/pptx/notes.js +85 -0
  58. package/dist-lib/lib/office/engine/pptx/presentation.d.ts +54 -0
  59. package/dist-lib/lib/office/engine/pptx/presentation.js +749 -0
  60. package/dist-lib/lib/office/engine/pptx/read.d.ts +9 -0
  61. package/dist-lib/lib/office/engine/pptx/read.js +66 -0
  62. package/dist-lib/lib/office/engine/pptx/shapes.d.ts +52 -0
  63. package/dist-lib/lib/office/engine/pptx/shapes.js +276 -0
  64. package/dist-lib/lib/office/engine/pptx/slides.d.ts +27 -0
  65. package/dist-lib/lib/office/engine/pptx/slides.js +123 -0
  66. package/dist-lib/lib/office/engine/pptx/template.d.ts +20 -0
  67. package/dist-lib/lib/office/engine/pptx/template.js +391 -0
  68. package/dist-lib/lib/office/engine/pptx/types.d.ts +120 -0
  69. package/dist-lib/lib/office/engine/pptx/types.js +1 -0
  70. package/dist-lib/lib/office/engine/units.d.ts +3 -0
  71. package/dist-lib/lib/office/engine/units.js +12 -0
  72. package/dist-lib/lib/office/engine/utf8.d.ts +2 -0
  73. package/dist-lib/lib/office/engine/utf8.js +103 -0
  74. package/dist-lib/lib/office/engine/xlsx/formula.d.ts +49 -0
  75. package/dist-lib/lib/office/engine/xlsx/formula.js +381 -0
  76. package/dist-lib/lib/office/engine/xlsx/index.d.ts +8 -0
  77. package/dist-lib/lib/office/engine/xlsx/index.js +8 -0
  78. package/dist-lib/lib/office/engine/xlsx/refs.d.ts +22 -0
  79. package/dist-lib/lib/office/engine/xlsx/refs.js +82 -0
  80. package/dist-lib/lib/office/engine/xlsx/reshape.d.ts +21 -0
  81. package/dist-lib/lib/office/engine/xlsx/reshape.js +424 -0
  82. package/dist-lib/lib/office/engine/xlsx/sheet.d.ts +108 -0
  83. package/dist-lib/lib/office/engine/xlsx/sheet.js +662 -0
  84. package/dist-lib/lib/office/engine/xlsx/strings.d.ts +15 -0
  85. package/dist-lib/lib/office/engine/xlsx/strings.js +93 -0
  86. package/dist-lib/lib/office/engine/xlsx/styles.d.ts +38 -0
  87. package/dist-lib/lib/office/engine/xlsx/styles.js +369 -0
  88. package/dist-lib/lib/office/engine/xlsx/template.d.ts +7 -0
  89. package/dist-lib/lib/office/engine/xlsx/template.js +54 -0
  90. package/dist-lib/lib/office/engine/xlsx/workbook.d.ts +161 -0
  91. package/dist-lib/lib/office/engine/xlsx/workbook.js +850 -0
  92. package/dist-lib/lib/office/engine/xlsx/write.d.ts +2 -0
  93. package/dist-lib/lib/office/engine/xlsx/write.js +42 -0
  94. package/dist-lib/lib/office/engine/xml.d.ts +45 -0
  95. package/dist-lib/lib/office/engine/xml.js +258 -0
  96. package/dist-lib/lib/office/extract.d.ts +9 -0
  97. package/dist-lib/lib/office/extract.js +19 -0
  98. package/dist-lib/lib/office/index.d.ts +1 -1
  99. package/dist-lib/lib/office/index.js +74 -19
  100. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +0 -3
  101. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +7 -50
  102. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +38 -695
  103. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +1 -85
  104. package/dist-lib/lib/tools/exec/scope.d.ts +2 -0
  105. package/dist-lib/lib/tools/exec/scope.js +33 -2
  106. package/dist-lib/lib/tools/exec.js +9 -20
  107. package/dist-lib/lib/tools/office/ops.d.ts +13 -0
  108. package/dist-lib/lib/tools/office/ops.js +100 -0
  109. package/dist-lib/lib/tools/office.d.ts +12 -0
  110. package/dist-lib/lib/tools/office.js +486 -0
  111. package/dist-lib/lib/tools/pdf/build.d.ts +43 -0
  112. package/dist-lib/lib/tools/pdf/build.js +150 -0
  113. package/dist-lib/lib/tools/pdf/images.d.ts +7 -0
  114. package/dist-lib/lib/tools/pdf/images.js +82 -0
  115. package/dist-lib/lib/tools/pdf/request.d.ts +63 -0
  116. package/dist-lib/lib/tools/pdf/request.js +335 -0
  117. package/dist-lib/lib/tools/pdf.d.ts +6 -0
  118. package/dist-lib/lib/tools/pdf.js +302 -0
  119. package/dist-lib/lib/tools/skill.js +14 -0
  120. package/package.json +34 -6
  121. package/wheels/manifest.json +1 -160
  122. package/worker/harness-sidecar.mjs +5 -1
  123. package/dist-lib/lib/office/pptx.d.ts +0 -1
  124. package/dist-lib/lib/office/pptx.js +0 -26
  125. package/dist-lib/lib/office/python.d.ts +0 -8
  126. package/dist-lib/lib/office/python.js +0 -32
  127. package/dist-lib/lib/office/xlsx.d.ts +0 -1
  128. package/dist-lib/lib/office/xlsx.js +0 -26
  129. package/dist-lib/lib/tools/exec/adapters/python/create-effect.d.ts +0 -7
  130. package/dist-lib/lib/tools/exec/adapters/python/create-effect.js +0 -318
  131. package/wheels/cffi-2.0.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl +0 -0
  132. package/wheels/cryptography-47.0.0-cp314-abi3-pyemscripten_2026_0_wasm32.whl +0 -0
  133. package/wheels/defusedxml-0.7.1-py2.py3-none-any.whl +0 -0
  134. package/wheels/et_xmlfile-2.0.0-py3-none-any.whl +0 -0
  135. package/wheels/fonttools-4.62.1-py3-none-any.whl +0 -0
  136. package/wheels/fpdf2-2.8.8-py3-none-any.whl +0 -0
  137. package/wheels/lxml-6.0.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl +0 -0
  138. package/wheels/openpyxl-3.1.5-py2.py3-none-any.whl +0 -0
  139. package/wheels/pillow-12.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl +0 -0
  140. package/wheels/pycparser-3.0-py3-none-any.whl +0 -0
  141. package/wheels/pypdf-6.15.0-py3-none-any.whl +0 -0
  142. package/wheels/python_docx-1.2.0-py3-none-any.whl +0 -0
  143. package/wheels/python_pptx-1.0.2-py3-none-any.whl +0 -0
  144. package/wheels/six-1.17.0-py2.py3-none-any.whl +0 -0
  145. package/wheels/typing_extensions-4.15.0-py3-none-any.whl +0 -0
  146. package/wheels/xlsxwriter-3.2.9-py3-none-any.whl +0 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,90 @@ While the major version is `0`, a breaking change bumps the minor version.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.8.5] - 2026-10-06
12
+
13
+ ### Added
14
+
15
+ - Skill-gated `pptx`, `docx` and `xlsx` tools on the office engine. A call takes
16
+ an `attachmentId` (or `create` options), `ops` that name engine methods with
17
+ named arguments, and an `output` file name; with an `attachmentId` alone it
18
+ returns the file's contents. The first failing op saves nothing and returns
19
+ the engine's error. An edit is never saved under the attached file's own
20
+ name. Saving the same file again in one turn answers with the one already
21
+ attached, and a changed file under that name is refused. The one-line answer
22
+ comes back as `reply`. These are the tools the presentations, word and excel
23
+ skills in `@qvac/skills` 0.1.12 call. (#286)
24
+
25
+ ### Changed
26
+
27
+ - **Breaking:** `@qvac/skills` is now 0.1.12, whose office and pdf skills run on
28
+ the `pptx`, `docx`, `xlsx` and `pdf` tools instead of Python. A host that
29
+ passes its own `skillsDir` needs 0.1.12 as well: older office skills import
30
+ Python libraries this release no longer ships. (#286)
31
+ - `extractOfficeText` and `extractDocxText` read .docx, .xlsx and .pptx in
32
+ process on the engine instead of starting Pyodide, and read at most 1M cells
33
+ across a workbook. Extracted workbook booleans read `true`/`false` instead of
34
+ `True`/`False`. `closeOfficeWorker` and `closeDocxWorker` remain as no-ops.
35
+ (#286)
36
+ - `exec` refuses Python that imports `pptx`, `docx` or `openpyxl`, or declares
37
+ their packages, before it runs, and names the tool (or the skill that grants
38
+ it) to use instead. (#287)
39
+ - **Breaking:** Python run through `exec` asks for approval like any other
40
+ write, including a run that only builds a new PDF. (#289)
41
+
42
+ ### Removed
43
+
44
+ - **Breaking:** the vendored Python wheels for python-pptx, python-docx,
45
+ openpyxl, fpdf2 and pypdf, and their dependencies. Only micropip is still
46
+ seeded; any other package installs from PyPI, which sandbox mode refuses.
47
+ Use the office and `pdf` tools. (#286, #289)
48
+ - The `exec` guidance written for python-pptx, python-docx and fpdf2 code:
49
+ their error hints and source checks, and the shrink-to-fit pass on saved
50
+ decks. (#288, #289)
51
+
52
+ ## [0.8.0] - 2026-10-05
53
+
54
+ ### Added
55
+
56
+ - A skill-gated `pdf` tool on `bare-pdfium` 1.6.0. With an `attachmentId` and
57
+ no `action` it reads a PDF: the page count, each page's text and the form
58
+ fields. With an `action` it saves a new file named `output` and attaches it:
59
+ `create` from Markdown (chat images placed with `![caption](attachmentId)`),
60
+ `merge`, `split`, `rotate`, `watermark`, `fill`, `encrypt`, `decrypt` and
61
+ `text`. Errors name what exists, a locked PDF takes its password on any
62
+ action, and the one-line answer comes back as `reply`. On Node it reports
63
+ that the device cannot open PDFs. The pdf skill in the next `@qvac/skills`
64
+ release uses it instead of Python. (#256)
65
+ - An office engine in `lib/office/engine/` that reads, edits and creates `.pptx`,
66
+ `.docx` and `.xlsx` files in TypeScript on Bare and Node, so office editing no
67
+ longer needs Python and can run on mobile. It keeps untouched parts byte for
68
+ byte, fails with a `DocsError` whose code names the problem, and moves Excel
69
+ references when rows and columns change. It adds `@xmldom/xmldom`, makes
70
+ `bare-zlib` a direct dependency and maps `#zlib` to `bare-zlib` or `zlib`.
71
+
72
+ ### Fixed
73
+
74
+ - Qwen3.5-family models: a tool call that ran goes back into the chat history
75
+ in the Qwen3.5 template's own format (`<tool_call>` wrapper, one line per tag,
76
+ string values as written) instead of a compact `<function=…>` line. A model
77
+ several calls into a turn copied the compact form, the tool grammar rejected
78
+ it, and the run ended in "This didn't finish". Models with another tool format
79
+ (e.g. MedPsy) still get a format that is not their own, until the SDK accepts
80
+ structured tool calls in history. (QVAC-23092, #146)
81
+ - A skill loaded with a `file` it does not have, before its instructions were
82
+ loaded, now returns those instructions with a line naming the files it has,
83
+ so the skill counts as loaded and its skill-gated tools appear. Before, a
84
+ one-file skill answered that the model already had its instructions, and its
85
+ tools stayed hidden. (#257)
86
+ - After a tool attaches a file, the loop tells the model the file is shown and
87
+ to answer, allows one more tool round, then writes the answer in a tool-less
88
+ round. Small models no longer keep calling tools on their own output until
89
+ the round cap. (#258)
90
+ - `exec`: a `$` before a digit, a space or punctuation in a shell command is
91
+ escaped instead of rejected, so text like `$1,299` runs on the first try. Real
92
+ shell expansion is still refused, now with a hint to single-quote the text.
93
+ (#208)
94
+
11
95
  ## [0.7.3] - 2026-10-01
12
96
 
13
97
  ### Added
@@ -1065,7 +1149,9 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1065
1149
  - GPU stats are read through `bare-gpu-info`, so every dependency installs from
1066
1150
  the public npm registry. (#12)
1067
1151
 
1068
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...HEAD
1152
+ [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.8.5...HEAD
1153
+ [0.8.5]: https://github.com/tetherto/qvac-harness/compare/v0.8.0...v0.8.5
1154
+ [0.8.0]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...v0.8.0
1069
1155
  [0.7.3]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...v0.7.3
1070
1156
  [0.7.2]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...v0.7.2
1071
1157
  [0.7.1]: https://github.com/tetherto/qvac-harness/compare/v0.7.0...v0.7.1
package/NOTICE.md CHANGED
@@ -11,3 +11,19 @@ distributed under the License is distributed on an "AS IS" BASIS,
11
11
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  See the License for the specific language governing permissions and
13
13
  limitations under the License.
14
+
15
+ ## Third-party components
16
+
17
+ ### @xmldom/xmldom
18
+
19
+ XML parsing and serialization for the office engine. Copyright 2019 - present
20
+ Christopher J. Brody and other contributors; Copyright 2012 - 2017 @jindw and
21
+ other contributors. Licensed under the MIT License.
22
+ <https://github.com/xmldom/xmldom>
23
+
24
+ ## Test data
25
+
26
+ The files under `test/fixtures/office-corpus` are taken from the Apache POI test
27
+ data. Copyright The Apache Software Foundation. Licensed under the Apache
28
+ License, Version 2.0. <https://github.com/apache/poi>. They are used by the test
29
+ suite only and are not part of the published package.
@@ -123,6 +123,7 @@ export interface RunBudget {
123
123
  export interface ToolExecuteContext {
124
124
  agentId?: string | null | undefined;
125
125
  skills?: readonly string[] | undefined;
126
+ skillsLoadedThisTurn?: readonly string[] | undefined;
126
127
  ctxSize?: number | undefined;
127
128
  image?: ImageSettings | undefined;
128
129
  settings?: Record<string, unknown> | undefined;
@@ -29,7 +29,7 @@ import { resolveImplicitSkill, resolveSkillMention, skillPreloadMessage } from '
29
29
  import { NETWORK_TOOLSETS, permittedTools } from '../tools/toolsets.js';
30
30
  import { promptPrefixHash } from './prompt-prefix.js';
31
31
  import { logToolPrompt } from './tool-prompt-log.js';
32
- import { DIRECT_CALL_ID, loadedSkillFilesFrom, loadedSkillsFrom, TOOL_CONTINUATION_NUDGE, toolResultTurn, withoutSkillLoadTurns } from './tool-turns.js';
32
+ import { DIRECT_CALL_ID, deliversFile, FILE_DELIVERED_NUDGE, loadedSkillFilesFrom, loadedSkillsFrom, TOOL_CONTINUATION_NUDGE, toolResultTurn, withoutSkillLoadTurns } from './tool-turns.js';
33
33
  export function imageSettingsFrom(agent) {
34
34
  if (!agent)
35
35
  return undefined;
@@ -153,7 +153,7 @@ function assistantToolTurn(contentText, calls) {
153
153
  if (!calls.length || contentText.includes('<function='))
154
154
  return contentText;
155
155
  const markup = toolCallMarkup(calls);
156
- return contentText ? `${contentText}\n${markup}` : markup;
156
+ return contentText ? `${contentText}\n\n${markup}` : markup;
157
157
  }
158
158
  function preloadLastUser(messages, name, body) {
159
159
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -734,17 +734,18 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
734
734
  let skillIcon = null;
735
735
  const skillEmoji = (name) => skillCatalog.find((entry) => entry.name === name)?.emoji ?? null;
736
736
  const nudgedSkills = new Set();
737
- function recordSkillLoad(skillName, output, { mainRequested, askedFile }) {
737
+ function recordSkillLoad(skillName, output, { mainRequested, askedFile, executed }) {
738
738
  if (!output || typeof output !== 'object' || !('content' in output)) {
739
739
  return;
740
740
  }
741
741
  loadedSkills.add(skillName);
742
- if ('file' in output && typeof output.file === 'string') {
742
+ const loadedFile = 'file' in output && typeof output.file === 'string' ? output.file : null;
743
+ if (loadedFile !== null) {
743
744
  const files = loadedSkillFiles.get(skillName) ?? new Set();
744
- files.add(output.file);
745
+ files.add(loadedFile);
745
746
  loadedSkillFiles.set(skillName, files);
746
747
  }
747
- if (mainRequested) {
748
+ if (mainRequested || (executed && loadedFile === null)) {
748
749
  loadedThisTurn.add(skillName);
749
750
  }
750
751
  else {
@@ -928,6 +929,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
928
929
  for (; round < MAX_TOOL_ROUNDS; round++) {
929
930
  if (signal?.aborted)
930
931
  throw new AbortedError();
932
+ let deliveredThisRound = false;
931
933
  if (Date.now() >= runBudget.deadlineMs) {
932
934
  outcome = 'time-capped';
933
935
  break;
@@ -1059,6 +1061,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1059
1061
  skills: [...loadedSkills].filter((name) => !skillsSelected ||
1060
1062
  (agent?.enabledSkills ?? []).includes(name) ||
1061
1063
  name === mention?.name),
1064
+ skillsLoadedThisTurn: [...loadedThisTurn],
1062
1065
  settings: toolConfigs?.[call.name],
1063
1066
  toolOps: grantedOps,
1064
1067
  fsRoots,
@@ -1165,7 +1168,11 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1165
1168
  webSearches++;
1166
1169
  }
1167
1170
  if (call.name === SKILL_TOOL_NAME) {
1168
- recordSkillLoad(skillName, output, { mainRequested, askedFile });
1171
+ recordSkillLoad(skillName, output, {
1172
+ mainRequested,
1173
+ askedFile,
1174
+ executed: execution.executed
1175
+ });
1169
1176
  }
1170
1177
  if (!activity &&
1171
1178
  !silent &&
@@ -1196,6 +1203,9 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1196
1203
  if (isMutation && executionOutcome.kind === 'success') {
1197
1204
  runOutcomes.confirmWrite(output);
1198
1205
  }
1206
+ if (executionOutcome.kind === 'success' && deliversFile(output)) {
1207
+ deliveredThisRound = true;
1208
+ }
1199
1209
  const turn = toolResultTurn(call.name, call.id, output);
1200
1210
  sources.push({
1201
1211
  origin: call.name === SKILL_TOOL_NAME ? 'skill' : 'tool-result',
@@ -1215,7 +1225,17 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1215
1225
  outcome = 'answered';
1216
1226
  break;
1217
1227
  }
1218
- history.push({ role: 'user', content: TOOL_CONTINUATION_NUDGE });
1228
+ history.push({
1229
+ role: 'user',
1230
+ content: deliveredThisRound ? FILE_DELIVERED_NUDGE : TOOL_CONTINUATION_NUDGE
1231
+ });
1232
+ if (runOutcomes.facts().delivered) {
1233
+ outcome = 'delivered';
1234
+ break;
1235
+ }
1236
+ if (deliveredThisRound) {
1237
+ runOutcomes.confirmDelivery();
1238
+ }
1219
1239
  if (blockedRepeats >= MAX_BLOCKED_REPEATS) {
1220
1240
  break;
1221
1241
  }
@@ -1,6 +1,9 @@
1
1
  import type { Message } from './completion.ts';
2
2
  export declare const DIRECT_CALL_ID = "call-direct";
3
3
  export declare const TOOL_CONTINUATION_NUDGE = "[Tool results above. Continue the user's request: call the next tool, or answer.]";
4
+ export declare const FILE_DELIVERED_NUDGE = "[Tool results above. The file is attached to the chat and shown to the user. Answer now, unless the request still needs a file that is not made yet.]";
5
+ export declare function isContinuationNudge(content: string): boolean;
6
+ export declare function deliversFile(output: unknown): boolean;
4
7
  export declare function toolResultTurn(name: string, id: string, output: unknown): string;
5
8
  export declare function skillLoadedTurn(name: string): string;
6
9
  export declare function attachmentTurn(name: string, result: unknown): string | null;
@@ -2,6 +2,13 @@ import { isRecord } from '../guards.js';
2
2
  import { SKILL_TOOL_NAME } from './tool-names.js';
3
3
  export const DIRECT_CALL_ID = 'call-direct';
4
4
  export const TOOL_CONTINUATION_NUDGE = "[Tool results above. Continue the user's request: call the next tool, or answer.]";
5
+ export const FILE_DELIVERED_NUDGE = '[Tool results above. The file is attached to the chat and shown to the user. Answer now, unless the request still needs a file that is not made yet.]';
6
+ export function isContinuationNudge(content) {
7
+ return content === TOOL_CONTINUATION_NUDGE || content === FILE_DELIVERED_NUDGE;
8
+ }
9
+ export function deliversFile(output) {
10
+ return isRecord(output) && Array.isArray(output.attachments) && output.attachments.length > 0;
11
+ }
5
12
  export function toolResultTurn(name, id, output) {
6
13
  return `[Tool: ${name} (${id})]\n${JSON.stringify(historyPayload(output))}`;
7
14
  }
@@ -1,7 +1,7 @@
1
- import { TOOL_CONTINUATION_NUDGE } from './capabilities/tool-turns.js';
1
+ import { isContinuationNudge } from './capabilities/tool-turns.js';
2
2
  export const TOOL_RESULT_TRUNCATION_MARKER = '\n[tool result truncated for context]\n';
3
3
  function isTurnBoundary(message) {
4
- return message.role === 'user' && message.content !== TOOL_CONTINUATION_NUDGE;
4
+ return message.role === 'user' && !isContinuationNudge(message.content);
5
5
  }
6
6
  export function contextCandidates(history) {
7
7
  const { prefix, turns } = splitTurns(history);
@@ -1,5 +1,5 @@
1
1
  import type { AbortSignal } from 'bare-abort-controller';
2
- export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/active.ts';
2
+ export declare function closeDocxWorker(): Promise<void>;
3
3
  export interface DocxExtractOptions {
4
4
  readonly signal?: AbortSignal;
5
5
  }
@@ -1,26 +1,18 @@
1
- import { extractWithPython } from '../office/python.js';
2
- export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/active.js';
3
- const PACKAGES = ['python-docx==1.2.0'];
4
- const EXTRACT = `from docx import Document
5
- from docx.text.paragraph import Paragraph
6
- from pathlib import Path
7
-
8
- def text(container):
9
- for block in container.iter_inner_content():
10
- if isinstance(block, Paragraph):
11
- if block.text.strip():
12
- yield block.text
13
- else:
14
- for row in block.rows:
15
- yield " | ".join(" ".join(text(cell)) for cell in row.cells)
16
-
17
- Path("extracted.txt").write_text("\\n".join(text(Document("input.docx"))), encoding="utf-8")
18
- `;
1
+ import { docx } from '../office/engine/index.js';
2
+ import { cellText, extractWith } from '../office/extract.js';
3
+ export async function closeDocxWorker() { }
4
+ function documentText(bytes) {
5
+ const lines = [];
6
+ for (const block of docx.open(bytes).read().blocks) {
7
+ if (block.type === 'table') {
8
+ lines.push(...block.rows.map((row) => row.map(cellText).join(' | ')));
9
+ }
10
+ else if (block.type === 'paragraph' && block.text.trim()) {
11
+ lines.push(block.text);
12
+ }
13
+ }
14
+ return lines.join('\n');
15
+ }
19
16
  export async function extractDocxText(bytes, fileName, options = {}) {
20
- return extractWithPython(bytes, fileName, {
21
- format: 'DOCX',
22
- packages: PACKAGES,
23
- source: EXTRACT,
24
- signal: options.signal
25
- });
17
+ return extractWith(bytes, { fileName, signal: options.signal, extract: documentText });
26
18
  }
@@ -1,13 +1,15 @@
1
1
  import type { ToolExecutionOutcome } from '../tool-call-display.ts';
2
- export type RunOutcome = 'answered' | 'mute' | 'exhausted' | 'search-capped' | 'time-capped';
2
+ export type RunOutcome = 'answered' | 'mute' | 'exhausted' | 'search-capped' | 'time-capped' | 'delivered';
3
3
  export declare const DEGENERATE_ANSWER_FALLBACK = "I couldn't complete that step and don't have a useful result to share.";
4
4
  export declare const ROUND_CAP_ANSWER = "I ran out of steps before I could finish that. Ask me to continue and I'll pick up where I left off.";
5
5
  export declare const SEARCH_CAPPED_ANSWER = "I searched the web but couldn't find the answer in the results I got.";
6
+ export declare const DELIVERED_ANSWER = "Your file is attached to the chat.";
6
7
  export declare const WRITE_RESULT_LEAD = "Here's what came back:";
7
8
  export declare const TIME_CAPPED_ANSWER = "I hit this run's time budget before I could finish that. Ask me to continue and I'll pick up where I left off.";
8
9
  export interface RunOutcomeFacts {
9
10
  readonly latestFailure: string | null;
10
11
  readonly confirmedWrites: ReadonlyArray<string>;
12
+ readonly delivered: boolean;
11
13
  }
12
14
  export interface HistoryTurn {
13
15
  readonly role: string;
@@ -19,6 +21,7 @@ export interface RunOutcomeTracker {
19
21
  record(result: unknown, outcome: ToolExecutionOutcome): void;
20
22
  finishRound(): void;
21
23
  confirmWrite(result: unknown): void;
24
+ confirmDelivery(): void;
22
25
  facts(): RunOutcomeFacts;
23
26
  }
24
27
  export declare function createRunOutcomeTracker(): RunOutcomeTracker;
@@ -1,8 +1,9 @@
1
- import { TOOL_CONTINUATION_NUDGE } from '../capabilities/tool-turns.js';
1
+ import { isContinuationNudge } from '../capabilities/tool-turns.js';
2
2
  import { isRecord } from '../guards.js';
3
3
  export const DEGENERATE_ANSWER_FALLBACK = "I couldn't complete that step and don't have a useful result to share.";
4
4
  export const ROUND_CAP_ANSWER = "I ran out of steps before I could finish that. Ask me to continue and I'll pick up where I left off.";
5
5
  export const SEARCH_CAPPED_ANSWER = "I searched the web but couldn't find the answer in the results I got.";
6
+ export const DELIVERED_ANSWER = 'Your file is attached to the chat.';
6
7
  export const WRITE_RESULT_LEAD = "Here's what came back:";
7
8
  export const TIME_CAPPED_ANSWER = "I hit this run's time budget before I could finish that. Ask me to continue and I'll pick up where I left off.";
8
9
  const MAX_FAILURE_CHARS = 300;
@@ -18,6 +19,7 @@ export function createRunOutcomeTracker() {
18
19
  let roundFailure = null;
19
20
  let latestFailure = null;
20
21
  let hasRoundResult = false;
22
+ let delivered = false;
21
23
  return {
22
24
  confirmedWrites,
23
25
  beginRound() {
@@ -42,8 +44,11 @@ export function createRunOutcomeTracker() {
42
44
  confirmedWrites.push(write);
43
45
  }
44
46
  },
47
+ confirmDelivery() {
48
+ delivered = true;
49
+ },
45
50
  facts() {
46
- return { latestFailure, confirmedWrites };
51
+ return { latestFailure, confirmedWrites, delivered };
47
52
  }
48
53
  };
49
54
  }
@@ -64,11 +69,12 @@ export function failureFromToolExecution(result, outcome) {
64
69
  }
65
70
  export function degenerateAnswer(outcome, history, facts) {
66
71
  const rest = (facts ? facts.latestFailure : lastFailedToolAnswer(history)) ?? cappedAnswer(outcome, history);
67
- const written = writeReport(facts?.confirmedWrites ?? []);
68
- if (written) {
69
- return rest ? `${written}\n\n${rest}` : written;
70
- }
71
- return rest ?? DEGENERATE_ANSWER_FALLBACK;
72
+ const lines = [
73
+ writeReport(facts?.confirmedWrites ?? []),
74
+ facts?.delivered ? DELIVERED_ANSWER : null,
75
+ rest
76
+ ].filter((line) => line !== null);
77
+ return lines.length ? lines.join('\n\n') : DEGENERATE_ANSWER_FALLBACK;
72
78
  }
73
79
  function cappedAnswer(outcome, history) {
74
80
  if (outcome === 'search-capped') {
@@ -108,7 +114,7 @@ function searchCappedAnswer(history) {
108
114
  export function lastFailedToolAnswer(history) {
109
115
  for (let i = history.length - 1; i >= 0; i--) {
110
116
  const turn = history[i];
111
- if (turn?.role === 'user' && turn.content === TOOL_CONTINUATION_NUDGE) {
117
+ if (turn?.role === 'user' && isContinuationNudge(turn.content)) {
112
118
  continue;
113
119
  }
114
120
  if (turn?.role !== 'tool') {
@@ -120,12 +120,12 @@ export function toolCallMarkup(calls) {
120
120
  }
121
121
  function markupForCall(call) {
122
122
  const params = Object.entries(call.arguments ?? {})
123
- .map(([key, value]) => `<parameter=${key}>${encodeMarkupValue(value)}</parameter>`)
123
+ .map(([key, value]) => `<parameter=${key}>\n${markupValue(value)}\n</parameter>\n`)
124
124
  .join('');
125
- return `<function=${call.name}>${params}</function>`;
125
+ return `<tool_call>\n<function=${call.name}>\n${params}</function>\n</tool_call>`;
126
126
  }
127
- function encodeMarkupValue(value) {
128
- return JSON.stringify(value ?? null).replace(/</g, '\\u003c');
127
+ function markupValue(value) {
128
+ return typeof value === 'string' ? value : JSON.stringify(value ?? null);
129
129
  }
130
130
  const ARG_WRAPPER_KEYS = new Set(['parameters', 'arguments', 'args', 'input', 'params']);
131
131
  function isPlainObject(value) {
@@ -1,6 +1,7 @@
1
1
  import { jsonrepair } from 'jsonrepair';
2
2
  import { isRecord } from '../../guards.js';
3
3
  import { hlog } from '../../log.js';
4
+ import { escapeLiteralDollars } from '../../tools/exec/scope.js';
4
5
  const STRUCTURED_TYPES = new Set(['object', 'array']);
5
6
  export class ArgRepairHook {
6
7
  phase = 'preToolCall';
@@ -24,6 +25,12 @@ export class ArgRepairHook {
24
25
  }
25
26
  args = { ...args, [key]: parsed };
26
27
  }
28
+ if (ctx.toolName === 'exec' && args.language !== 'python' && typeof args.command === 'string') {
29
+ const command = escapeLiteralDollars(args.command);
30
+ if (command !== args.command) {
31
+ args = { ...args, command };
32
+ }
33
+ }
27
34
  if (args !== ctx.args) {
28
35
  return { args };
29
36
  }
@@ -0,0 +1,62 @@
1
+ export declare const CODES: {
2
+ readonly INVALID_INPUT: 'the input is not a Uint8Array or Buffer';
3
+ readonly NOT_OOXML: 'the file is not a zip archive with [Content_Types].xml';
4
+ readonly NOT_PPTX: 'the file is not a PowerPoint presentation';
5
+ readonly NOT_DOCX: 'the file is not a Word document';
6
+ readonly NOT_XLSX: 'the file is not an Excel workbook';
7
+ readonly UNSUPPORTED_PPTX: 'the presentation uses Strict Open XML';
8
+ readonly UNSUPPORTED_DOCX: 'the document uses Strict Open XML';
9
+ readonly UNSUPPORTED_XLSX: 'the workbook uses Strict Open XML';
10
+ readonly BROKEN_PPTX: 'the presentation points at parts that do not exist';
11
+ readonly BROKEN_DOCX: 'the document is missing a required part';
12
+ readonly BROKEN_XLSX: 'the workbook points at parts that do not exist';
13
+ readonly TOO_LARGE: 'the file exceeds the entry, size or compression-ratio limits';
14
+ readonly UNSAFE_XML: 'an XML part declares a DOCTYPE or nests too deeply';
15
+ readonly INVALID_XML: 'an XML part is not well-formed';
16
+ readonly PART_EXISTS: 'a part with that name already exists';
17
+ readonly PART_MISSING: 'the part is not in the package';
18
+ readonly UNTOUCHED_EDIT: 'a part was changed without touch() (strict mode only)';
19
+ readonly INVALID_OPTIONS: 'an option is unknown or out of range';
20
+ readonly INVALID_TEXT: 'text or paragraphs are not in an accepted shape';
21
+ readonly INVALID_STYLE: 'a style or format value is unknown or out of range';
22
+ readonly INVALID_BOX: 'a position or size is not a valid whole number of EMU';
23
+ readonly INVALID_IMAGE: 'the image is empty, truncated or has no size';
24
+ readonly UNSUPPORTED_IMAGE: 'the image is not PNG, JPEG or GIF';
25
+ readonly INVALID_TABLE: 'table rows or cells are not in an accepted shape';
26
+ readonly INVALID_CHART: 'a chart option is not valid';
27
+ readonly INVALID_CHART_DATA: 'chart categories or series are not valid';
28
+ readonly UNSUPPORTED_CHART: 'that chart type cannot be created or edited';
29
+ readonly CHART_COMBO: 'a combo chart must keep its number of series';
30
+ readonly NOT_CHART: 'the target holds no chart';
31
+ readonly SLIDE_NOT_FOUND: 'no slide at that index';
32
+ readonly SHAPE_NOT_FOUND: 'no shape with that id or role on the slide';
33
+ readonly LAYOUT_NOT_FOUND: 'no layout with that name';
34
+ readonly PARAGRAPH_NOT_FOUND: 'no paragraph at that index';
35
+ readonly NOT_TEXT: 'the shape or block holds no text';
36
+ readonly NOT_PICTURE: 'the shape is not a picture';
37
+ readonly NOT_TABLE: 'the shape or block is not a table';
38
+ readonly CELL_NOT_FOUND: 'no table cell at that row and column';
39
+ readonly CELL_MERGED: 'the cell is covered by a merged cell';
40
+ readonly SLIDE_IS_LINKED: 'another slide links to the slide';
41
+ readonly TOO_MANY_SLIDES: 'the deck has run out of slide ids';
42
+ readonly BLOCK_NOT_FOUND: 'no block at that index';
43
+ readonly NOT_PARAGRAPH: 'the block is not a paragraph';
44
+ readonly NOT_HEADING: 'the block is not a heading';
45
+ readonly STYLE_NOT_FOUND: 'no style with that name';
46
+ readonly TRACKED_CHANGES: 'the paragraph has tracked changes that an edit would accept silently';
47
+ readonly ROW_NOT_FOUND: 'no table row at that index';
48
+ readonly ROW_MERGED: 'the row is part of a vertically merged cell';
49
+ readonly LAST_ROW: 'the table would be left without rows';
50
+ readonly SHEET_NOT_FOUND: 'no sheet with that name or index';
51
+ readonly NOT_WORKSHEET: 'the sheet is a chart sheet or dialog sheet';
52
+ readonly INVALID_NAME: 'the sheet name is not allowed';
53
+ readonly INVALID_CELL: 'not a valid cell, row or column reference';
54
+ readonly INVALID_RANGE: 'not a valid range or limit';
55
+ readonly INVALID_VALUE: 'a cell value, formula or date is not valid';
56
+ readonly INVALID_FORMULA: 'text starting with = does not parse as a formula';
57
+ readonly SHARED_FORMULA: 'the cell belongs to a shared or array formula range';
58
+ readonly SHEET_HAS_DEPENDENTS: 'the sheet has pivot tables, comments or other parts that rows or columns cannot move through yet';
59
+ readonly SHEET_IS_REFERENCED: 'other formulas, names or charts refer to the sheet';
60
+ readonly LAST_SHEET: 'a workbook needs at least one visible sheet';
61
+ };
62
+ export type DocsErrorCode = keyof typeof CODES;
@@ -0,0 +1,61 @@
1
+ export const CODES = {
2
+ INVALID_INPUT: 'the input is not a Uint8Array or Buffer',
3
+ NOT_OOXML: 'the file is not a zip archive with [Content_Types].xml',
4
+ NOT_PPTX: 'the file is not a PowerPoint presentation',
5
+ NOT_DOCX: 'the file is not a Word document',
6
+ NOT_XLSX: 'the file is not an Excel workbook',
7
+ UNSUPPORTED_PPTX: 'the presentation uses Strict Open XML',
8
+ UNSUPPORTED_DOCX: 'the document uses Strict Open XML',
9
+ UNSUPPORTED_XLSX: 'the workbook uses Strict Open XML',
10
+ BROKEN_PPTX: 'the presentation points at parts that do not exist',
11
+ BROKEN_DOCX: 'the document is missing a required part',
12
+ BROKEN_XLSX: 'the workbook points at parts that do not exist',
13
+ TOO_LARGE: 'the file exceeds the entry, size or compression-ratio limits',
14
+ UNSAFE_XML: 'an XML part declares a DOCTYPE or nests too deeply',
15
+ INVALID_XML: 'an XML part is not well-formed',
16
+ PART_EXISTS: 'a part with that name already exists',
17
+ PART_MISSING: 'the part is not in the package',
18
+ UNTOUCHED_EDIT: 'a part was changed without touch() (strict mode only)',
19
+ INVALID_OPTIONS: 'an option is unknown or out of range',
20
+ INVALID_TEXT: 'text or paragraphs are not in an accepted shape',
21
+ INVALID_STYLE: 'a style or format value is unknown or out of range',
22
+ INVALID_BOX: 'a position or size is not a valid whole number of EMU',
23
+ INVALID_IMAGE: 'the image is empty, truncated or has no size',
24
+ UNSUPPORTED_IMAGE: 'the image is not PNG, JPEG or GIF',
25
+ INVALID_TABLE: 'table rows or cells are not in an accepted shape',
26
+ INVALID_CHART: 'a chart option is not valid',
27
+ INVALID_CHART_DATA: 'chart categories or series are not valid',
28
+ UNSUPPORTED_CHART: 'that chart type cannot be created or edited',
29
+ CHART_COMBO: 'a combo chart must keep its number of series',
30
+ NOT_CHART: 'the target holds no chart',
31
+ SLIDE_NOT_FOUND: 'no slide at that index',
32
+ SHAPE_NOT_FOUND: 'no shape with that id or role on the slide',
33
+ LAYOUT_NOT_FOUND: 'no layout with that name',
34
+ PARAGRAPH_NOT_FOUND: 'no paragraph at that index',
35
+ NOT_TEXT: 'the shape or block holds no text',
36
+ NOT_PICTURE: 'the shape is not a picture',
37
+ NOT_TABLE: 'the shape or block is not a table',
38
+ CELL_NOT_FOUND: 'no table cell at that row and column',
39
+ CELL_MERGED: 'the cell is covered by a merged cell',
40
+ SLIDE_IS_LINKED: 'another slide links to the slide',
41
+ TOO_MANY_SLIDES: 'the deck has run out of slide ids',
42
+ BLOCK_NOT_FOUND: 'no block at that index',
43
+ NOT_PARAGRAPH: 'the block is not a paragraph',
44
+ NOT_HEADING: 'the block is not a heading',
45
+ STYLE_NOT_FOUND: 'no style with that name',
46
+ TRACKED_CHANGES: 'the paragraph has tracked changes that an edit would accept silently',
47
+ ROW_NOT_FOUND: 'no table row at that index',
48
+ ROW_MERGED: 'the row is part of a vertically merged cell',
49
+ LAST_ROW: 'the table would be left without rows',
50
+ SHEET_NOT_FOUND: 'no sheet with that name or index',
51
+ NOT_WORKSHEET: 'the sheet is a chart sheet or dialog sheet',
52
+ INVALID_NAME: 'the sheet name is not allowed',
53
+ INVALID_CELL: 'not a valid cell, row or column reference',
54
+ INVALID_RANGE: 'not a valid range or limit',
55
+ INVALID_VALUE: 'a cell value, formula or date is not valid',
56
+ INVALID_FORMULA: 'text starting with = does not parse as a formula',
57
+ SHARED_FORMULA: 'the cell belongs to a shared or array formula range',
58
+ SHEET_HAS_DEPENDENTS: 'the sheet has pivot tables, comments or other parts that rows or columns cannot move through yet',
59
+ SHEET_IS_REFERENCED: 'other formulas, names or charts refer to the sheet',
60
+ LAST_SHEET: 'a workbook needs at least one visible sheet'
61
+ };