tether-sdk 0.2.6__tar.gz → 0.2.7__tar.gz

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 (201) hide show
  1. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/AGENTS.md +1 -1
  2. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/PKG-INFO +1 -1
  3. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/README.md +8 -0
  4. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/grade.py +47 -2
  5. tether_sdk-0.2.7/bench/report.py +48 -0
  6. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/run.py +29 -4
  7. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/architecture.md +2 -2
  8. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/context-and-store.md +1 -1
  9. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/providers.md +1 -1
  10. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/release-notes.md +13 -0
  11. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_faults.py +12 -0
  12. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_context.py +24 -1
  13. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_harness.py +13 -0
  14. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_params.py +30 -0
  15. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_tether.py +1 -1
  16. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/__init__.py +1 -1
  17. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/context.py +6 -2
  18. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/harness.py +3 -2
  19. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/anthropic.py +3 -0
  20. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/base.py +17 -1
  21. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/ollama.py +3 -0
  22. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/openai.py +3 -0
  23. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.claude/feature-context/active/.gitkeep +0 -0
  24. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.claude/feature-context/active/v0-2-bugs.md +0 -0
  25. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.claude/feature-context/archive/.gitkeep +0 -0
  26. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.github/workflows/ci.yml +0 -0
  27. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.github/workflows/delivery.yml +0 -0
  28. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.github/workflows/docs.yml +0 -0
  29. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.gitignore +0 -0
  30. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/LICENSE +0 -0
  31. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/README.md +0 -0
  32. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/harness-bench/README.md +0 -0
  33. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/harness-bench/tether-adapter.patch +0 -0
  34. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/naive_agent.py +0 -0
  35. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/goal.txt +0 -0
  36. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/hidden/test_hidden.py +0 -0
  37. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/__init__.py +0 -0
  38. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/errors.py +0 -0
  39. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/evaluator.py +0 -0
  40. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/parser.py +0 -0
  41. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/tokenizer.py +0 -0
  42. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/__init__.py +0 -0
  43. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/errors.py +0 -0
  44. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/evaluator.py +0 -0
  45. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/parser.py +0 -0
  46. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/tokenizer.py +0 -0
  47. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/tests/__init__.py +0 -0
  48. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/tests/test_calc.py +0 -0
  49. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/goal.txt +0 -0
  50. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/hidden/test_hidden.py +0 -0
  51. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/cli.py +0 -0
  52. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/ledger/__init__.py +0 -0
  53. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/ledger/parser.py +0 -0
  54. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/ledger/report.py +0 -0
  55. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/sample.csv +0 -0
  56. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/tests/__init__.py +0 -0
  57. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/tests/test_report.py +0 -0
  58. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/goal.txt +0 -0
  59. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/hidden/test_hidden.py +0 -0
  60. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/ws/tests/__init__.py +0 -0
  61. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/ws/tests/test_textutils.py +0 -0
  62. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/ws/textutils.py +0 -0
  63. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/codereview/goal.txt +0 -0
  64. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/codereview/ws/inventory.py +0 -0
  65. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/content/goal.txt +0 -0
  66. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/content/ws/.keep +0 -0
  67. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/goal.txt +0 -0
  68. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/hidden/test_hidden.py +0 -0
  69. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/AGENTS.md +0 -0
  70. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/README.md +0 -0
  71. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/docs/architecture.md +0 -0
  72. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/docs/style.md +0 -0
  73. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/__init__.py +0 -0
  74. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/cli.py +0 -0
  75. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/fmt.py +0 -0
  76. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/stats.py +0 -0
  77. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/textio.py +0 -0
  78. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/tests/__init__.py +0 -0
  79. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/tests/test_stats.py +0 -0
  80. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/goal.txt +0 -0
  81. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/README.md +0 -0
  82. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/svc/__init__.py +0 -0
  83. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/svc/client.py +0 -0
  84. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/svc/config.py +0 -0
  85. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/qa/goal.txt +0 -0
  86. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/qa/ws/.keep +0 -0
  87. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/review/goal.txt +0 -0
  88. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/review/ws/paper.md +0 -0
  89. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/benchmarks.md +0 -0
  90. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/extending.md +0 -0
  91. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/index.md +0 -0
  92. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/javascripts/mathjax.js +0 -0
  93. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/agent-interface.md +0 -0
  94. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/harness.md +0 -0
  95. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/index.md +0 -0
  96. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/multiplexer-and-decorator.md +0 -0
  97. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/sandbox-and-skills.md +0 -0
  98. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/tools.md +0 -0
  99. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/releasing.md +0 -0
  100. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/testing.md +0 -0
  101. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/troubleshooting.md +0 -0
  102. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/agent-interface.md +0 -0
  103. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/built-in-tools.md +0 -0
  104. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/cli-and-headless.md +0 -0
  105. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/index.md +0 -0
  106. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/multi-agent.md +0 -0
  107. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/sdk-and-custom-tools.md +0 -0
  108. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/security-and-sandbox.md +0 -0
  109. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/skills.md +0 -0
  110. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/workflows-and-telemetry.md +0 -0
  111. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/__init__.py +0 -0
  112. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/conftest.py +0 -0
  113. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/proxy.py +0 -0
  114. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_agents.py +0 -0
  115. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_defaults.py +0 -0
  116. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_lifecycle.py +0 -0
  117. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_limits.py +0 -0
  118. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_quirks.py +0 -0
  119. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_sandbox.py +0 -0
  120. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_surface.py +0 -0
  121. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/README.md +0 -0
  122. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/checks.py +0 -0
  123. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/course.yaml +0 -0
  124. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/ruff.toml +0 -0
  125. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/planner/SKILL.md +0 -0
  126. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/searcher/SKILL.md +0 -0
  127. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/searcher/tools.py +0 -0
  128. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/writer/SKILL.md +0 -0
  129. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/writer/tools.py +0 -0
  130. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/mkdocs.yml +0 -0
  131. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/pyproject.toml +0 -0
  132. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/scripts/record_golden.py +0 -0
  133. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/skills/researcher/SKILL.md +0 -0
  134. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/__init__.py +0 -0
  135. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/conftest.py +0 -0
  136. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/fixtures/dialects.jsonl +0 -0
  137. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/coding_edit_failure_modes.json +0 -0
  138. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/coding_edit_failure_modes.snap.json +0 -0
  139. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/done_nudge.json +0 -0
  140. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/done_nudge.snap.json +0 -0
  141. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/read_range_pingpong.json +0 -0
  142. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/read_range_pingpong.snap.json +0 -0
  143. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/research_web.json +0 -0
  144. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/research_web.snap.json +0 -0
  145. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/shell_wrappers.json +0 -0
  146. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/shell_wrappers.snap.json +0 -0
  147. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/waiting_input_resume.json +0 -0
  148. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/waiting_input_resume.snap.json +0 -0
  149. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/__init__.py +0 -0
  150. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_anthropic.py +0 -0
  151. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_base.py +0 -0
  152. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_declarative.py +0 -0
  153. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_dialects.py +0 -0
  154. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_ollama.py +0 -0
  155. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_openai.py +0 -0
  156. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_retry.py +0 -0
  157. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_agent_interface.py +0 -0
  158. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_cli.py +0 -0
  159. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_context_config.py +0 -0
  160. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_decorator.py +0 -0
  161. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_edit_tool.py +0 -0
  162. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_golden.py +0 -0
  163. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_integration.py +0 -0
  164. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_misses.py +0 -0
  165. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_multiplexer.py +0 -0
  166. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_pipeline.py +0 -0
  167. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_release_hardening.py +0 -0
  168. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_retro.py +0 -0
  169. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_sandbox.py +0 -0
  170. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_security_hardening.py +0 -0
  171. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_skills.py +0 -0
  172. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_store.py +0 -0
  173. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_structured_history.py +0 -0
  174. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/__init__.py +0 -0
  175. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_agent.py +0 -0
  176. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_bash.py +0 -0
  177. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_done.py +0 -0
  178. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_edit.py +0 -0
  179. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_mem.py +0 -0
  180. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_web.py +0 -0
  181. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/api.py +0 -0
  182. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/cli.py +0 -0
  183. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/decorator.py +0 -0
  184. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/mcp.py +0 -0
  185. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/multiplexer.py +0 -0
  186. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/pipeline.py +0 -0
  187. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/__init__.py +0 -0
  188. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/declarative.py +0 -0
  189. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/retro.py +0 -0
  190. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/sandbox.py +0 -0
  191. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/schema.py +0 -0
  192. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/skills.py +0 -0
  193. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/store.py +0 -0
  194. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/__init__.py +0 -0
  195. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/agent.py +0 -0
  196. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/bash.py +0 -0
  197. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/done.py +0 -0
  198. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/edit.py +0 -0
  199. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/mem.py +0 -0
  200. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/web.py +0 -0
  201. {tether_sdk-0.2.6 → tether_sdk-0.2.7}/uv.lock +0 -0
@@ -62,7 +62,7 @@ Reviewers and the Team Leader cite the failing line when they reject a change.
62
62
  ```text
63
63
  [obs_3 folded: `web fetch https://...` (3,420 chars) | use `mem recall obs_3` to expand]
64
64
  ```
65
- - Exception: the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The 2,400-char head+tail elision still applies, and under `max_context_tokens` pressure it is folded like any other (oldest first). The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command").
65
+ - Exception: the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The head+tail elision still applies (an observation may use up to a third of `max_context_tokens`, at least 2,400 chars), and under `max_context_tokens` pressure it is folded like any other (oldest first). The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command").
66
66
  - History is replayed as structured `tool_calls` + `tool` results (providers with `structured_history = True`; textual `act('...')` form only as fallback), with `stdin` replayed as a real 400-char prefix, never a `<N chars>` placeholder. A call and its result are trimmed together.
67
67
 
68
68
  ### Rule 6: Deterministic Security Envelope (Never Trust the LLM for Safety)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tether-sdk
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: General-purpose, headless-first LLM-Native micro-harness for small language models
5
5
  Project-URL: Homepage, https://github.com/igormcsouza/tether
6
6
  Project-URL: Repository, https://github.com/igormcsouza/tether
@@ -9,6 +9,14 @@ Nine tasks in `tasks/<name>/` (workspace in `ws/`, goal in `goal.txt`, hidden te
9
9
  scores 0), `qa`, `content`, `review` (graded by constraint counts and keyword checks, so they are
10
10
  a smoke test for quality, not a judge).
11
11
 
12
+ **Polyglot-Python** is the score we answer to. `--tasks polyglot` runs the 34 Python exercises of Aider's
13
+ [polyglot-benchmark](https://github.com/Aider-AI/polyglot-benchmark) (cloned once into
14
+ `~/.cache/tether-bench/polyglot-benchmark`, or set `POLYGLOT_DIR`); `--tasks polyglot-fast` runs a 12-exercise
15
+ subset that mixes passes, partials and failures. Each task is graded by the exercise's own tests (the test file is
16
+ restored before grading, so editing it cannot pass), `--poly-verify` adds a `--verify` gate on those tests. Then
17
+ `python3 bench/report.py <out>` prints pass rate and mean score with a failure breakdown that keeps provider outages
18
+ (`outage`, `timeout`) apart from harness outcomes (`max_steps`, `stalled`, `wrong_answer`, `worker_error`).
19
+
12
20
  Three runners, so a gap can be attributed:
13
21
 
14
22
  | runner | what it is |
@@ -176,10 +176,55 @@ def _codereview(_task, _ws, result):
176
176
  return {"score": round(sum(hit.values()) / len(hit), 3), "pass": all(hit[k] for k in must), "detail": {"missed": [k for k, v in hit.items() if not v], "items": _steps(t)}}
177
177
 
178
178
 
179
+ # Fast set (12): easy passes, partials and hard failures from the 0.2.6 baseline; tether's weak spots.
180
+ POLY_FAST = ("affine-cipher bottle-song list-ops paasio dot-dsl food-chain forth go-counting poker pov react "
181
+ "rest-api").split()
182
+
183
+
184
+ def poly_dir(ex=""):
185
+ """Aider polyglot-benchmark Python exercises (cloned once, never vendored)."""
186
+ root = os.environ.get("POLYGLOT_DIR") or os.path.expanduser("~/.cache/tether-bench/polyglot-benchmark")
187
+ if not os.path.isdir(root):
188
+ subprocess.run(["git", "clone", "--depth", "1", "https://github.com/Aider-AI/polyglot-benchmark", root], check=True)
189
+ return os.path.join(root, "python", "exercises", "practice", ex)
190
+
191
+
192
+ def poly_goal(ex):
193
+ d = poly_dir(ex)
194
+ docs = "\n\n".join(open(os.path.join(d, ".docs", f)).read() for f in ("instructions.md", "instructions.append.md")
195
+ if os.path.exists(os.path.join(d, ".docs", f)))
196
+ sol = ex.replace("-", "_") + ".py"
197
+ return (f"Implement `{sol}` in the current directory so that `python3 -m unittest discover -p '*_test.py'` passes. "
198
+ f"Do not edit the test file.\n\n{docs}")
199
+
200
+
201
+ def _poly(task, ws, _result):
202
+ ex = task.removeprefix("poly-")
203
+ tmp = tempfile.mkdtemp(prefix="g_")
204
+ try:
205
+ shutil.copytree(ws, tmp, dirs_exist_ok=True)
206
+ test = ex.replace("-", "_") + "_test.py"
207
+ shutil.copy(os.path.join(poly_dir(ex), test), os.path.join(tmp, test)) # restore: editing tests cannot pass
208
+ try:
209
+ p = subprocess.run([sys.executable, "-m", "unittest", "-v", test[:-3]], cwd=tmp, capture_output=True, text=True, timeout=90)
210
+ except subprocess.TimeoutExpired:
211
+ return {"score": 0.0, "pass": False, "detail": {"error": "tests timed out"}}
212
+ out = p.stderr + p.stdout
213
+ ok = len(re.findall(r"\.\.\. ok$", out, re.M))
214
+ bad = len(re.findall(r"\.\.\. (FAIL|ERROR)$", out, re.M))
215
+ total = ok + bad
216
+ return {"score": round(ok / total, 3) if total else 0.0, "pass": p.returncode == 0 and total > 0, "detail": {"ok": ok, "total": total}}
217
+ finally:
218
+ shutil.rmtree(tmp, ignore_errors=True)
219
+
220
+
179
221
  def grade(task, ws, result):
180
- fn = {"qa": _qa, "content": _content, "review": _review, "focus": _focus, "plan": _plan, "codereview": _codereview}.get(task, _code)
222
+ orig = task
223
+ if task.startswith("poly-"):
224
+ task = "poly"
225
+ fn = {"poly": _poly, "qa": _qa, "content": _content, "review": _review, "focus": _focus, "plan": _plan, "codereview": _codereview}.get(task, _code)
181
226
  try:
182
- return fn(task, ws, result)
227
+ return fn(orig, ws, result)
183
228
  except Exception as e: # grader bug must never look like a model result
184
229
  return {"score": 0.0, "pass": False, "detail": {"grader_error": repr(e)}}
185
230
 
@@ -0,0 +1,48 @@
1
+ """Failure breakdown for a results dir: `python3 bench/report.py <out> [<out> ...]`.
2
+
3
+ Splits every run into pass, model-side outcomes (wrong answer, ran out of steps, stalled) and
4
+ failures that say nothing about the harness's logic (provider outage, killed by timeout), so a
5
+ score never hides infrastructure noise and infrastructure never hides a harness bug.
6
+ """
7
+ import collections
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+
13
+ OUTAGE = re.compile(r"unavailable after|429|503|Not Found|ConnectError|TimeoutError")
14
+
15
+
16
+ def outcome(r):
17
+ if r["pass"]:
18
+ return "pass"
19
+ try:
20
+ j = json.loads(open(r["dir"] + "/stdout.txt").read())
21
+ except (OSError, ValueError):
22
+ return "timeout" if r.get("run_status") == "timeout" else "no_output"
23
+ f = j.get("failure") or {}
24
+ kind, msg = f.get("kind"), f.get("message", "")
25
+ if kind == "worker_error":
26
+ return "outage" if OUTAGE.search(msg) else "worker_error"
27
+ return kind or ("wrong_answer" if j.get("status") == "COMPLETED" else "timeout")
28
+
29
+
30
+ def main(dirs):
31
+ rows = []
32
+ for d in dirs:
33
+ rows += [json.loads(line) for line in open(os.path.join(d, "results.jsonl"))]
34
+ groups = collections.defaultdict(list)
35
+ for r in rows:
36
+ groups[(r["runner"], r["model"])].append(r)
37
+ for (runner, model), rs in sorted(groups.items()):
38
+ c = collections.Counter(outcome(r) for r in rs)
39
+ valid = [r for r in rs if outcome(r) not in ("outage", "timeout", "no_output")]
40
+ mean = lambda l: round(sum(x["score"] for x in l) / len(l), 3) if l else 0.0 # noqa: E731
41
+ wall = round(sum(r["wall_s"] for r in rs) / len(rs))
42
+ print(f"{runner} {model}: {len(rs)} runs, pass {c['pass']} ({c['pass'] / len(rs):.0%}), mean {mean(rs)}, "
43
+ f"mean over valid runs {mean(valid)}, {wall}s/run")
44
+ print(" " + ", ".join(f"{k} {v}" for k, v in c.most_common()))
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main(sys.argv[1:])
@@ -25,6 +25,17 @@ TASK_CFG = { # tether tools / step cap / wall timeout
25
25
  }
26
26
 
27
27
 
28
+ POLY_VERIFY = False
29
+
30
+
31
+ def cfg(task):
32
+ return ("edit,bash", 30, 600) if task.startswith("poly-") else TASK_CFG[task]
33
+
34
+
35
+ def poly_tasks():
36
+ return ",".join("poly-" + e for e in sorted(os.listdir(grade.poly_dir())))
37
+
38
+
28
39
  def group(model):
29
40
  return "nvidia" if model.startswith("openai/") else "ollama" if model.startswith("ollama/") else "claude" if model.startswith(("claude", "cc/")) else "nvidia"
30
41
 
@@ -40,7 +51,7 @@ def flags(tether_bin):
40
51
 
41
52
 
42
53
  def build_cmd(runner, model, task, goal, ws, rd, tether_bin, params=()):
43
- tools, steps, _ = TASK_CFG[task]
54
+ tools, steps, _ = cfg(task)
44
55
  if runner == "tether":
45
56
  h = flags(tether_bin)
46
57
  cmd = [tether_bin, "run", "--model", model, "--max-steps", str(steps), "--db", f"{rd}/t.db"]
@@ -50,6 +61,8 @@ def build_cmd(runner, model, task, goal, ws, rd, tether_bin, params=()):
50
61
  cmd += ["--tools", tools]
51
62
  for item in params:
52
63
  cmd += ["--param", item]
64
+ if task.startswith("poly-") and POLY_VERIFY:
65
+ cmd += ["--verify", "python3 -m unittest discover -p '*_test.py'"]
53
66
  return cmd + [goal]
54
67
  if runner == "naive":
55
68
  base, key = ("http://localhost:11435/v1", "") if model.startswith("ollama/") else ("https://integrate.api.nvidia.com/v1", "NVIDIA_API_KEY")
@@ -99,8 +112,13 @@ async def one(sem, rec, args, results):
99
112
  async with sem[group(model)]:
100
113
  shutil.rmtree(rd, ignore_errors=True)
101
114
  os.makedirs(rd)
102
- shutil.copytree(f"{HERE}/tasks/{task}/ws", ws)
103
- goal = open(f"{HERE}/tasks/{task}/goal.txt").read().strip()
115
+ if task.startswith("poly-"):
116
+ ex = task[5:]
117
+ shutil.copytree(grade.poly_dir(ex), ws, ignore=shutil.ignore_patterns(".meta", ".docs"))
118
+ goal = grade.poly_goal(ex)
119
+ else:
120
+ shutil.copytree(f"{HERE}/tasks/{task}/ws", ws)
121
+ goal = open(f"{HERE}/tasks/{task}/goal.txt").read().strip()
104
122
  cmd = build_cmd(runner, model, task, goal, ws, rd, args.tether_bin, args.param)
105
123
  env = dict(os.environ, OLLAMA_HOST="http://localhost:11435", PYTHONDONTWRITEBYTECODE="1")
106
124
  if model.startswith("openai/"): # old tether has no providers.yaml: route openai/ to NVIDIA's OpenAI-compatible API
@@ -109,7 +127,7 @@ async def one(sem, rec, args, results):
109
127
  p = await asyncio.create_subprocess_exec(*cmd, cwd=ws, env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=True)
110
128
  status = "ok"
111
129
  try:
112
- out, err = await asyncio.wait_for(p.communicate(), TASK_CFG[task][2])
130
+ out, err = await asyncio.wait_for(p.communicate(), cfg(task)[2])
113
131
  except asyncio.TimeoutError:
114
132
  os.killpg(p.pid, signal.SIGKILL)
115
133
  out, err = await p.communicate()
@@ -161,7 +179,14 @@ async def main():
161
179
  ap.add_argument("--par", default="nvidia=4,ollama=1,claude=3")
162
180
  ap.add_argument("--force", action="store_true")
163
181
  ap.add_argument("--redo-infra", action="store_true", help="rerun cells that failed on provider errors")
182
+ ap.add_argument("--poly-verify", action="store_true", help="verify gate on the exercise tests: gate runs at `done`")
164
183
  args = ap.parse_args()
184
+ global POLY_VERIFY
185
+ POLY_VERIFY = args.poly_verify
186
+ if args.tasks == "polyglot":
187
+ args.tasks = poly_tasks()
188
+ elif args.tasks == "polyglot-fast":
189
+ args.tasks = ",".join("poly-" + e for e in grade.POLY_FAST)
165
190
  os.makedirs(args.out, exist_ok=True)
166
191
  sem = {k: asyncio.Semaphore(int(v)) for k, v in (x.split("=") for x in args.par.split(","))}
167
192
  recs = [(r, m, t, k) for r in args.runners.split(",") for m in args.models.split(",") for t in args.tasks.split(",") for k in range(args.trials)
@@ -53,12 +53,12 @@ In *"MemGPT: Towards LLMs as Operating Systems"*, Packer et al. (2023) introduce
53
53
 
54
54
  Tether implements a deterministic three-tier memory hierarchy backed by `.tether/tether.db` (SQLite WAL + FTS5):
55
55
 
56
- 1. **Head + Tail L1 Active Elision**: Even within the active $K=2$ turn window, if a command emits an oversized output (`> 2,400 chars`), `ContextAssembler` keeps the first 900 chars (headers/command summary) + last 900 chars (stack trace/exit status) and elides the middle with a pointer to `mem recall <obs_id>`.
56
+ 1. **Head + Tail L1 Active Elision**: Even within the active $K=2$ turn window, if a command emits an oversized output (more than a third of `max_context_tokens` in chars, at least 2,400), `ContextAssembler` keeps the first 60% of the cap (headers/command summary) + the last 40% (stack trace/exit status) and elides the middle with a pointer to `mem recall <obs_id>`.
57
57
  2. **Ephemeral $K$-Turn Folding**: Once an observation ages past $K=2$ steps, it is folded into a single-line tombstone:
58
58
  ```text
59
59
  [obs_1 folded: `web fetch https://...` (3,420 chars) | use `mem recall obs_1` or `mem search <query>`]
60
60
  ```
61
- Exception (#69): the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The 2,400-char head+tail elision still applies, and the hard context budget remains #81. The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command").
61
+ Exception (#69): the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The head+tail elision still applies (an observation may use up to a third of `max_context_tokens`, at least 2,400 chars), and the hard context budget remains #81. The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command").
62
62
  3. **Sub-Millisecond SQLite FTS5 BM25 RAG**: Every raw observation and final deliverable is automatically chunked (~150 tokens per paragraph/section) and indexed into an FTS5 virtual table (`chunks_fts`) so the agent (or a parent agent) can retrieve exact passages via `mem search` or `agent query`.
63
63
 
64
64
  ---
@@ -22,7 +22,7 @@ When the provider sets `structured_history = True` (Ollama, OpenAI-compatible, A
22
22
 
23
23
  Raw tool outputs stay in the prompt for $K=2$ turns, then fold into a one-line tombstone (see [Built-in Tools](../tutorial/built-in-tools.md#mem-pinned-scratchpad-bm25-observation-recall)). For small models this has consequences ([#27](https://github.com/igormcsouza/tether/issues/27)), seen in the [course-builder example](https://github.com/igormcsouza/tether/tree/main/examples/course):
24
24
 
25
- - **Fix (#69).** Folding exception: the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The 2,400-char head+tail elision still applies, and the hard context budget remains #81. The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command"). (`ContextAssembler(mem_enabled=..., workspace_root=...)`, `MAX_LIVE_READS`).
25
+ - **Fix (#69).** Folding exception: the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The head+tail elision still applies (an observation may use up to a third of `max_context_tokens`, at least 2,400 chars), and the hard context budget remains #81. The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command"). (`ContextAssembler(mem_enabled=..., workspace_root=...)`, `MAX_LIVE_READS`).
26
26
  - **Re-viewing files loops, then invents data.** The model runs `edit view`, the output folds two turns later, it views again, and eventually writes from memory.
27
27
  - **What worked: one compact joined view, used right before the write, that ends with the exact next call.** The `course-source <module>` tool returns a module's plan and chosen videos in one output and finishes with `NOW WRITE THIS MODULE: act("edit write part-N.md", ...)`, so the data is still in the prompt when the write happens.
28
28
  - **Write long outputs in small pieces.** One big write dropped a whole section. The writer skill writes one `part-N.md` per module with `edit write`, then a `course-build` tool concatenates them into `course.md`.
@@ -48,7 +48,7 @@ Every entry, of any kind, may set optional positive integers: `context_tokens` (
48
48
 
49
49
  ### Timeout and retry
50
50
 
51
- Every entry may set `timeout` (positive seconds). It applies to the HTTP kinds (`openai`, `ollama`, `anthropic`; defaults 120 / 120 / 180 s) and to `command` (default 300 s). The HTTP providers retry automatically, at most 2 times with 1 s then 2 s backoff, on timeouts, network errors and HTTP 429/500/502/503/504. A 401/403 is never retried: it raises `ProviderError` naming the provider and the credential variable (`openai: HTTP 401: `NVIDIA_API_KEY` is missing or rejected; set it to a valid key`; the value is never printed), and a declared `api_key_env` that is unset or empty fails the session before any request with the same message. Other 4xx responses are never retried. When retries run out the call raises `ProviderError`, so the session fails with a readable `Worker error: ProviderError: openai model `gpt-4o` unavailable after 3 attempts (timeout 120s, last error: ReadTimeout)`. The retry is invisible to the prompt (0 tokens, no prefix change).
51
+ Every entry may set `timeout` (positive seconds). It applies to the HTTP kinds (`openai`, `ollama`, `anthropic`; defaults 120 / 120 / 180 s) and to `command` (default 300 s). The HTTP providers retry automatically, at most 2 times with 1 s then 2 s backoff, on timeouts, network errors and HTTP 429/500/502/503/504. A 401/403 is never retried: it raises `ProviderError` naming the provider and the credential variable (`openai: HTTP 401: `NVIDIA_API_KEY` is missing or rejected; set it to a valid key`; the value is never printed), and a declared `api_key_env` that is unset or empty fails the session before any request with the same message. Other 4xx responses are never retried. An HTTP 500 whose body names a rejected tool call (Ollama: `invalid tool call arguments`, a cut-off tool-call JSON) is not retried either, since the same request fails the same way; `ollama`, `openai` and `anthropic` turn it into a one-line hint to the model (a no-action reply), so the session continues instead of failing. The match is the text `tool call` in a 500 body, for any server. When retries run out the call raises `ProviderError`, so the session fails with a readable `Worker error: ProviderError: openai model `gpt-4o` unavailable after 3 attempts (timeout 120s, last error: ReadTimeout)`. The retry is invisible to the prompt (0 tokens, no prefix change).
52
52
 
53
53
  ### Kinds
54
54
 
@@ -1,5 +1,18 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.2.7 (2026-10-24)
4
+
5
+ Found by running Tether on the Polyglot-Python benchmark (34 Aider polyglot Python exercises) and reading why runs failed instead of guessing: sessions that ended mid-task on a server error, and models that re-read one file until the step cap. Closes #138, #141.
6
+
7
+ ### Benchmark
8
+
9
+ - `bench/run.py --tasks polyglot|polyglot-fast` runs the Polyglot-Python exercises, graded by each exercise's own tests; `bench/report.py` prints pass rate, mean score and a failure breakdown that keeps provider outages apart from harness outcomes. On the 12-exercise fast set (2 trials), the observation-cap fix below moved `qwen3.5:9b` from mean 0.64 to 0.80 and cut re-reads of one file from 32% to 19% of steps; `nemotron-3.5-lightning` went from 10 to 12 passes of 24.
10
+
11
+ ### Fixes
12
+
13
+ - Context: one active observation may now use up to a third of the context budget (`max_context_tokens * 4 / 3` chars, at least 2,400) before head+tail elision, instead of a fixed 2,400 chars. Before, a 6-12 KB test file or a 100-line slice of one was always cut to head+tail, so the model asked for another slice, got that cut too, and re-read the same file 10+ times: on the Polyglot-Python fast set 24 of 48 runs ended at the step cap, 45% of their steps were reads and only 19% were edits. The hard budget still folds older observations first, so the prompt cannot overflow; `ContextAssembler(active_char_cap=...)` still sets a fixed cap.
14
+ - Providers: when a server (Ollama, vLLM, llama.cpp behind `openai`, `anthropic`) answers HTTP 500 because it rejected the model's own tool call (`invalid tool call arguments for "act": unexpected end of JSON input`), the call is no longer retried and no longer ends the session as `worker_error` (#138). The model gets one line (`Your last call was rejected: the tool-call JSON was invalid or cut off. Send a shorter call, e.g. write long files in smaller pieces.`) through the existing no-action reminder and carries on, bounded by `max_steps`. Before, 13 of 34 Polyglot-Python runs on `qwen3.5:9b` died this way, mid-task and often with most tests passing. `ModelResponse` gains an optional `hint`. 0 static prompt tokens.
15
+
3
16
  ## v0.2.6 (2026-10-23)
4
17
 
5
18
  Found by using tether as its own SWE agent and by the v0.2.5 post-release check: a sandbox that refused harmless commands, sessions that only read, and a `query` that could not say why it found nothing. Closes #77, #93, #125, #127.
@@ -26,6 +26,18 @@ def test_persistent_5xx_fails_loudly_with_exit_5(tether: Tether, chaos) -> None:
26
26
  assert "unavailable after 3 attempts" in r["failure"]["message"]
27
27
 
28
28
 
29
+ def test_rejected_tool_call_reaches_the_model_instead_of_ending_the_session(
30
+ tether: Tether, chaos
31
+ ) -> None:
32
+ """#138: a 500 naming an invalid tool call is model feedback, not a provider outage."""
33
+ chaos.fault("status", code=500, error='invalid tool call arguments for "act": unexpected end')
34
+ chaos.script(("done", "ok"))
35
+ r = tether.run("go", "--max-steps", "6")
36
+ assert (r["rc"], r["status"]) == (0, "COMPLETED"), r
37
+ assert len(chaos.requests) == 2, "the rejected call must not be retried"
38
+ assert "cut off" in "\n".join(t["content"] or "" for t in tether.turns(r["id"]))
39
+
40
+
29
41
  def test_trickling_response_hits_the_per_attempt_deadline(tmp_path: Path, chaos) -> None:
30
42
  """#88: bytes keep arriving so httpx's read timeout never fires; the deadline must."""
31
43
  chaos.fault("trickle", times=3, seconds=60)
@@ -245,7 +245,7 @@ def test_tombstone_hint_follows_mem_availability(tmp_path: Path) -> None:
245
245
  # active (not yet folded) head+tail elision marker follows the same rule
246
246
  store = SQLiteStore(db_path=tmp_path / "c.db")
247
247
  rec = store.create_session("g")
248
- obs = store.add_observation(rec.id, step=1, cmd="bash ls", raw_output="x" * 5000)
248
+ obs = store.add_observation(rec.id, step=1, cmd="bash ls", raw_output="x" * 20000)
249
249
  store.add_turn(rec.id, step=1, role="tool", content="", obs_id=obs.id)
250
250
  msgs = ContextAssembler(mem_enabled=False).assemble(
251
251
  system_prompt="S",
@@ -320,3 +320,26 @@ def test_mem_recall_results_are_not_folded_again(tmp_path: Path) -> None:
320
320
  # Folding a recall's own output sent GLM-5.3 on a 10-step `mem recall obs_N` chain (#86).
321
321
  text = _text(tmp_path, ["bash ls", "mem recall obs_1", "bash echo a", "bash echo b"])
322
322
  assert _live(text, 2) and "obs_1 folded" in text and "obs_3 folded" in text
323
+
324
+
325
+ def test_active_observation_cap_follows_the_context_budget(tmp_path: Path) -> None:
326
+ """A 9 KB test file is shown whole on the default budget, head+tail on a tiny one."""
327
+ store = SQLiteStore(db_path=tmp_path / "c.db")
328
+ rec = store.create_session("g")
329
+ obs = store.add_observation(rec.id, step=1, cmd="bash cat t.py", raw_output="x" * 9000)
330
+ store.add_turn(rec.id, step=1, role="tool", content="", obs_id=obs.id)
331
+
332
+ def shown(**kw: int) -> str:
333
+ msgs = ContextAssembler(**kw).assemble(
334
+ system_prompt="S",
335
+ goal="g",
336
+ turns=store.get_turns(rec.id),
337
+ observations={obs.id: obs},
338
+ pins={},
339
+ current_step=1,
340
+ )
341
+ return str(msgs)
342
+
343
+ assert "elided" not in shown()
344
+ assert "elided" in shown(max_context_tokens=2048)
345
+ assert ContextAssembler(max_context_tokens=100).active_char_cap == 2400
@@ -718,3 +718,16 @@ def test_awk_is_quiet_unless_it_writes(cmd: str, quiet: bool) -> None:
718
718
  )
719
719
  def test_comparison_operators_are_not_redirects(cmd: str, quiet: bool) -> None:
720
720
  assert is_quiet(cmd) is quiet
721
+
722
+
723
+ async def test_provider_hint_reaches_the_model_and_the_session_continues(tmp_path: Path) -> None:
724
+ provider = FakeScriptedProvider(
725
+ [
726
+ ModelResponse(content="", hint="Your last call was cut off."),
727
+ ModelResponse(content="", action=ActionCall(cmd="done", stdin="ok")),
728
+ ]
729
+ )
730
+ harness = Harness(provider=provider, db_path=tmp_path / "t.db", max_steps=5)
731
+ rec = await harness.run("go")
732
+ assert rec.status == "COMPLETED"
733
+ assert any("Your last call was cut off." in str(m) for m in provider.recorded_calls[-1])
@@ -125,3 +125,33 @@ async def test_cli_param_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]
125
125
  assert stored == {"temperature": 0, "think": False}
126
126
  assert await _async_main([*argv, "--param", "oops", "g"]) == 1
127
127
  assert "KEY=VALUE" in capsys.readouterr().err
128
+
129
+
130
+ async def test_ollama_rejected_tool_call_is_model_feedback_not_an_outage() -> None:
131
+ calls = 0
132
+
133
+ def handler(request: httpx.Request) -> httpx.Response:
134
+ nonlocal calls
135
+ calls += 1
136
+ return httpx.Response(
137
+ 500, text='{"error":"invalid tool call arguments for \\"act\\": unexpected end"}'
138
+ )
139
+
140
+ prov = OllamaProvider("m", client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
141
+ resp = await prov.generate([{"role": "user", "content": "x"}], [])
142
+ assert calls == 1 # the same request would fail the same way
143
+ assert resp.action is None and "cut off" in resp.hint
144
+
145
+
146
+ @pytest.mark.parametrize("make", [OpenAIProvider, AnthropicProvider])
147
+ async def test_other_http_providers_also_turn_a_rejected_tool_call_into_feedback(make: Any) -> None:
148
+ calls = 0
149
+
150
+ def handler(request: httpx.Request) -> httpx.Response:
151
+ nonlocal calls
152
+ calls += 1
153
+ return httpx.Response(500, text="invalid tool call arguments")
154
+
155
+ prov = make("m", client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
156
+ resp = await prov.generate([{"role": "user", "content": "x"}], [])
157
+ assert calls == 1 and resp.action is None and "cut off" in resp.hint
@@ -7,6 +7,6 @@ import tether
7
7
 
8
8
  def test_version_and_public_exports() -> None:
9
9
  """Verify package __version__ and public SDK symbols."""
10
- assert tether.__version__ == "0.2.6"
10
+ assert tether.__version__ == "0.2.7"
11
11
  for symbol in tether.__all__:
12
12
  assert hasattr(tether, symbol)
@@ -32,7 +32,7 @@ from tether.store import (
32
32
  ToolOffender,
33
33
  )
34
34
 
35
- __version__ = "0.2.6"
35
+ __version__ = "0.2.7"
36
36
 
37
37
  __all__ = [
38
38
  "ActionCall",
@@ -159,7 +159,7 @@ class ContextAssembler:
159
159
  *,
160
160
  fold_after_turns: int = 2,
161
161
  fold_char_threshold: int = 200,
162
- active_char_cap: int = 2400,
162
+ active_char_cap: int | None = None,
163
163
  max_context_tokens: int = 8192,
164
164
  structured: bool = False,
165
165
  mem_enabled: bool = True,
@@ -167,7 +167,11 @@ class ContextAssembler:
167
167
  ) -> None:
168
168
  self.fold_after_turns = fold_after_turns
169
169
  self.fold_char_threshold = fold_char_threshold
170
- self.active_char_cap = active_char_cap
170
+ # One observation may use a third of the context (4 chars/token); the budget still folds
171
+ # older ones first, so a 12 KB test file is seen whole instead of head+tail, then re-read.
172
+ self.active_char_cap = (
173
+ max(2400, max_context_tokens * 4 // 3) if active_char_cap is None else active_char_cap
174
+ )
171
175
  self.max_context_tokens = max_context_tokens
172
176
  # True: replay past actions as tool_calls + tool results (provider adapters translate);
173
177
  # False: textual fallback for providers without tool messages.
@@ -761,7 +761,8 @@ class Harness:
761
761
  session_id=session_id,
762
762
  step=step,
763
763
  role="user",
764
- content=(
764
+ content=resp.hint
765
+ or (
765
766
  "System reminder: Use `act(cmd, stdin)` to execute a tool command, "
766
767
  'or call `act("done", stdin="<final deliverable>")` to finish.'
767
768
  ),
@@ -1027,8 +1028,8 @@ class Harness:
1027
1028
  nudged = False
1028
1029
  stalls = 0
1029
1030
 
1030
- # One-shot nudge after QUIET_NUDGE_STEPS quiet steps following a change
1031
1031
  out = dispatch.output
1032
+ # One-shot nudge after QUIET_NUDGE_STEPS quiet steps following a change
1032
1033
  if changed and not nudged and quiet_run >= QUIET_NUDGE_STEPS:
1033
1034
  out += (
1034
1035
  f"\n[NUDGE] No file changes for {QUIET_NUDGE_STEPS} steps. If the task is "
@@ -14,6 +14,7 @@ from tether.providers.base import (
14
14
  extract_fallback_action,
15
15
  limiter_for,
16
16
  post_with_retry,
17
+ rejected_call,
17
18
  )
18
19
 
19
20
 
@@ -156,6 +157,8 @@ class AnthropicProvider:
156
157
  limiter=self._limiter,
157
158
  json=payload,
158
159
  )
160
+ if rejected := rejected_call(resp):
161
+ return rejected
159
162
  resp.raise_for_status()
160
163
  data = resp.json()
161
164
 
@@ -35,6 +35,7 @@ class ModelResponse:
35
35
  action: ActionCall | None = None
36
36
  prompt_tokens: int = 0
37
37
  completion_tokens: int = 0
38
+ hint: str = "" # shown to the model instead of the generic reminder when it sent no usable call
38
39
 
39
40
 
40
41
  ProviderResponse = ModelResponse
@@ -69,6 +70,21 @@ def auth_message(
69
70
 
70
71
 
71
72
  RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
73
+ # A server that rejects the model's own tool call (cut-off JSON) answers the same way every time.
74
+ MODEL_FAULT = "tool call"
75
+ REJECTED_CALL_HINT = (
76
+ "Your last call was rejected: the tool-call JSON was invalid or cut off. "
77
+ "Send a shorter call, e.g. write long files in smaller pieces."
78
+ )
79
+
80
+
81
+ def rejected_call(resp: httpx.Response) -> ModelResponse | None:
82
+ """Turn a server's 500 about the model's own bad tool call into feedback for the model."""
83
+ if resp.status_code == 500 and MODEL_FAULT in resp.text:
84
+ return ModelResponse(content="", hint=REJECTED_CALL_HINT)
85
+ return None
86
+
87
+
72
88
  _TRANSIENT = (httpx.TimeoutException, httpx.NetworkError, TimeoutError)
73
89
 
74
90
 
@@ -167,7 +183,7 @@ async def post_with_retry(
167
183
  continue
168
184
  if resp.status_code in (401, 403):
169
185
  raise ProviderError(auth_message(provider, key_env, resp.status_code, resp.text))
170
- if resp.status_code not in RETRY_STATUS:
186
+ if resp.status_code not in RETRY_STATUS or rejected_call(resp):
171
187
  return resp
172
188
  last = f"HTTP {resp.status_code}: {resp.text[:120]!r}"
173
189
  if resp.status_code == 429: # rate limits reset per minute: wait longer, honor Retry-After
@@ -15,6 +15,7 @@ from tether.providers.base import (
15
15
  extract_fallback_action,
16
16
  limiter_for,
17
17
  post_with_retry,
18
+ rejected_call,
18
19
  )
19
20
 
20
21
  # Request-level keys of /api/chat; everything else in `params` is a sampling option.
@@ -133,6 +134,8 @@ class OllamaProvider:
133
134
  self.model_name = fallback
134
135
  payload["model"] = fallback
135
136
  resp = await self._chat(client, payload)
137
+ if rejected := rejected_call(resp):
138
+ return rejected
136
139
  resp.raise_for_status()
137
140
  data = resp.json()
138
141
 
@@ -15,6 +15,7 @@ from tether.providers.base import (
15
15
  extract_fallback_action,
16
16
  limiter_for,
17
17
  post_with_retry,
18
+ rejected_call,
18
19
  )
19
20
 
20
21
 
@@ -95,6 +96,8 @@ class OpenAIProvider:
95
96
  limiter=self._limiter,
96
97
  json=payload,
97
98
  )
99
+ if rejected := rejected_call(resp):
100
+ return rejected
98
101
  resp.raise_for_status()
99
102
  data = resp.json()
100
103
 
File without changes
File without changes
File without changes