tether-sdk 0.2.5__tar.gz → 0.2.6__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 (200) hide show
  1. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/AGENTS.md +2 -2
  2. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/PKG-INFO +1 -1
  3. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/README.md +2 -2
  4. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/grade.py +42 -1
  5. tether_sdk-0.2.6/bench/harness-bench/README.md +25 -0
  6. tether_sdk-0.2.6/bench/harness-bench/tether-adapter.patch +134 -0
  7. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/run.py +1 -0
  8. tether_sdk-0.2.6/bench/tasks/codereview/goal.txt +1 -0
  9. tether_sdk-0.2.6/bench/tasks/codereview/ws/inventory.py +40 -0
  10. tether_sdk-0.2.6/bench/tasks/plan/goal.txt +1 -0
  11. tether_sdk-0.2.6/bench/tasks/plan/ws/README.md +3 -0
  12. tether_sdk-0.2.6/bench/tasks/plan/ws/svc/client.py +18 -0
  13. tether_sdk-0.2.6/bench/tasks/plan/ws/svc/config.py +3 -0
  14. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/release-notes.md +13 -0
  15. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/testing.md +1 -1
  16. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/security-and-sandbox.md +1 -1
  17. tether_sdk-0.2.6/e2e/__init__.py +0 -0
  18. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_limits.py +17 -0
  19. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_sandbox.py +15 -0
  20. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_cli.py +16 -0
  21. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_harness.py +57 -1
  22. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_security_hardening.py +52 -1
  23. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_store.py +23 -0
  24. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_tether.py +1 -1
  25. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/__init__.py +1 -1
  26. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/cli.py +15 -1
  27. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/harness.py +22 -2
  28. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/sandbox.py +30 -9
  29. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/store.py +18 -1
  30. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.claude/feature-context/active/.gitkeep +0 -0
  31. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.claude/feature-context/active/v0-2-bugs.md +0 -0
  32. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.claude/feature-context/archive/.gitkeep +0 -0
  33. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.github/workflows/ci.yml +0 -0
  34. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.github/workflows/delivery.yml +0 -0
  35. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.github/workflows/docs.yml +0 -0
  36. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/.gitignore +0 -0
  37. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/LICENSE +0 -0
  38. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/README.md +0 -0
  39. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/naive_agent.py +0 -0
  40. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/goal.txt +0 -0
  41. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/hidden/test_hidden.py +0 -0
  42. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ref/calc/__init__.py +0 -0
  43. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ref/calc/errors.py +0 -0
  44. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ref/calc/evaluator.py +0 -0
  45. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ref/calc/parser.py +0 -0
  46. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ref/calc/tokenizer.py +0 -0
  47. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/calc/__init__.py +0 -0
  48. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/calc/errors.py +0 -0
  49. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/calc/evaluator.py +0 -0
  50. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/calc/parser.py +0 -0
  51. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/calc/tokenizer.py +0 -0
  52. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/tests/__init__.py +0 -0
  53. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-hard/ws/tests/test_calc.py +0 -0
  54. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/goal.txt +0 -0
  55. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/hidden/test_hidden.py +0 -0
  56. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/cli.py +0 -0
  57. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/ledger/__init__.py +0 -0
  58. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/ledger/parser.py +0 -0
  59. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/ledger/report.py +0 -0
  60. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/sample.csv +0 -0
  61. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/tests/__init__.py +0 -0
  62. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-medium/ws/tests/test_report.py +0 -0
  63. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-simple/goal.txt +0 -0
  64. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-simple/hidden/test_hidden.py +0 -0
  65. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-simple/ws/tests/__init__.py +0 -0
  66. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-simple/ws/tests/test_textutils.py +0 -0
  67. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/code-simple/ws/textutils.py +0 -0
  68. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/content/goal.txt +0 -0
  69. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/content/ws/.keep +0 -0
  70. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/goal.txt +0 -0
  71. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/hidden/test_hidden.py +0 -0
  72. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/AGENTS.md +0 -0
  73. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/README.md +0 -0
  74. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/docs/architecture.md +0 -0
  75. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/docs/style.md +0 -0
  76. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/mini/__init__.py +0 -0
  77. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/mini/cli.py +0 -0
  78. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/mini/fmt.py +0 -0
  79. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/mini/stats.py +0 -0
  80. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/mini/textio.py +0 -0
  81. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/tests/__init__.py +0 -0
  82. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/focus/ws/tests/test_stats.py +0 -0
  83. {tether_sdk-0.2.5/e2e → tether_sdk-0.2.6/bench/tasks/plan/ws/svc}/__init__.py +0 -0
  84. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/qa/goal.txt +0 -0
  85. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/qa/ws/.keep +0 -0
  86. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/review/goal.txt +0 -0
  87. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/bench/tasks/review/ws/paper.md +0 -0
  88. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/architecture.md +0 -0
  89. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/benchmarks.md +0 -0
  90. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/extending.md +0 -0
  91. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/index.md +0 -0
  92. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/javascripts/mathjax.js +0 -0
  93. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/agent-interface.md +0 -0
  94. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/context-and-store.md +0 -0
  95. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/harness.md +0 -0
  96. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/index.md +0 -0
  97. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/multiplexer-and-decorator.md +0 -0
  98. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/providers.md +0 -0
  99. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/sandbox-and-skills.md +0 -0
  100. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/reference/tools.md +0 -0
  101. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/releasing.md +0 -0
  102. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/troubleshooting.md +0 -0
  103. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/agent-interface.md +0 -0
  104. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/built-in-tools.md +0 -0
  105. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/cli-and-headless.md +0 -0
  106. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/index.md +0 -0
  107. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/multi-agent.md +0 -0
  108. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/sdk-and-custom-tools.md +0 -0
  109. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/skills.md +0 -0
  110. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/docs/tutorial/workflows-and-telemetry.md +0 -0
  111. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/conftest.py +0 -0
  112. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/proxy.py +0 -0
  113. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_agents.py +0 -0
  114. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_defaults.py +0 -0
  115. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_faults.py +0 -0
  116. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_lifecycle.py +0 -0
  117. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_quirks.py +0 -0
  118. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/e2e/test_surface.py +0 -0
  119. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/README.md +0 -0
  120. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/checks.py +0 -0
  121. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/course.yaml +0 -0
  122. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/ruff.toml +0 -0
  123. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/skills/planner/SKILL.md +0 -0
  124. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/skills/searcher/SKILL.md +0 -0
  125. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/skills/searcher/tools.py +0 -0
  126. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/skills/writer/SKILL.md +0 -0
  127. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/examples/course/skills/writer/tools.py +0 -0
  128. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/mkdocs.yml +0 -0
  129. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/pyproject.toml +0 -0
  130. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/scripts/record_golden.py +0 -0
  131. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/skills/researcher/SKILL.md +0 -0
  132. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/__init__.py +0 -0
  133. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/conftest.py +0 -0
  134. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/fixtures/dialects.jsonl +0 -0
  135. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/coding_edit_failure_modes.json +0 -0
  136. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/coding_edit_failure_modes.snap.json +0 -0
  137. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/done_nudge.json +0 -0
  138. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/done_nudge.snap.json +0 -0
  139. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/read_range_pingpong.json +0 -0
  140. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/read_range_pingpong.snap.json +0 -0
  141. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/research_web.json +0 -0
  142. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/research_web.snap.json +0 -0
  143. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/shell_wrappers.json +0 -0
  144. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/shell_wrappers.snap.json +0 -0
  145. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/waiting_input_resume.json +0 -0
  146. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/golden/waiting_input_resume.snap.json +0 -0
  147. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/__init__.py +0 -0
  148. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_anthropic.py +0 -0
  149. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_base.py +0 -0
  150. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_declarative.py +0 -0
  151. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_dialects.py +0 -0
  152. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_ollama.py +0 -0
  153. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_openai.py +0 -0
  154. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/providers/test_retry.py +0 -0
  155. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_agent_interface.py +0 -0
  156. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_context.py +0 -0
  157. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_context_config.py +0 -0
  158. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_decorator.py +0 -0
  159. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_edit_tool.py +0 -0
  160. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_golden.py +0 -0
  161. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_integration.py +0 -0
  162. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_misses.py +0 -0
  163. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_multiplexer.py +0 -0
  164. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_params.py +0 -0
  165. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_pipeline.py +0 -0
  166. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_release_hardening.py +0 -0
  167. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_retro.py +0 -0
  168. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_sandbox.py +0 -0
  169. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_skills.py +0 -0
  170. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/test_structured_history.py +0 -0
  171. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/__init__.py +0 -0
  172. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/test_agent.py +0 -0
  173. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/test_bash.py +0 -0
  174. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/test_done.py +0 -0
  175. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/test_edit.py +0 -0
  176. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/test_mem.py +0 -0
  177. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tests/tools/test_web.py +0 -0
  178. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/api.py +0 -0
  179. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/context.py +0 -0
  180. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/decorator.py +0 -0
  181. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/mcp.py +0 -0
  182. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/multiplexer.py +0 -0
  183. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/pipeline.py +0 -0
  184. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/providers/__init__.py +0 -0
  185. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/providers/anthropic.py +0 -0
  186. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/providers/base.py +0 -0
  187. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/providers/declarative.py +0 -0
  188. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/providers/ollama.py +0 -0
  189. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/providers/openai.py +0 -0
  190. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/retro.py +0 -0
  191. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/schema.py +0 -0
  192. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/skills.py +0 -0
  193. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/__init__.py +0 -0
  194. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/agent.py +0 -0
  195. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/bash.py +0 -0
  196. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/done.py +0 -0
  197. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/edit.py +0 -0
  198. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/mem.py +0 -0
  199. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/tether/tools/web.py +0 -0
  200. {tether_sdk-0.2.5 → tether_sdk-0.2.6}/uv.lock +0 -0
@@ -45,7 +45,7 @@ Reviewers and the Team Leader cite the failing line when they reject a change.
45
45
  - Sessions do **not** terminate simply because the model emitted a text response without a tool call (small 9B models frequently "think out loud" before acting).
46
46
  - A task only completes when the agent explicitly calls `act("done", stdin="<final deliverable>")` (or `act("done '<short answer>'")`), pauses for human/parent input via `act("done --ask", stdin="<question>")` (transitioning the session to `WAITING_INPUT` and releasing the worker process until resumed via `send`), or hits `max_steps`.
47
47
  - If the model emits plain text without calling `act`, the harness appends a brief deterministic nudge reminding it to either execute a command or call `done`.
48
- - After 6 consecutive read-only/verification steps following a change, the harness appends a one-shot `[NUDGE]` to that step's observation reminding the model to call `done` if the task is complete (0 static prompt tokens, #74). Seven reads of one file in a row with no change are a `cycle_stall`, and 4 stall warnings without a change end the session `FAILED` as `Stalled` (#92); `tether stop <id>` terminates a detached worker (#89).
48
+ - After 6 consecutive read-only/verification steps following a change, the harness appends a one-shot `[NUDGE]` to that step's observation reminding the model to call `done` if the task is complete (0 static prompt tokens, #74). Before any change, 12 read-only steps in a row (then every 8 more) append a one-line `[NUDGE]` to make the edit or call `done` (#127). Seven reads of one file in a row with no change are a `cycle_stall`, and 4 stall warnings without a change end the session `FAILED` as `Stalled` (#92); `tether stop <id>` terminates a detached worker (#89).
49
49
  - When `verify` gate commands are configured (via `Harness(verify=[...])`, CLI `--verify`, or Skill YAML `verify: [...]`), `Harness.execute_session` deterministically executes each command inside the session's `SandboxPolicy` before allowing `done` to transition to `COMPLETED` (consuming 0 static prompt tokens until a gate fails). A gate that errors instead of failing (sandbox violation, exit 124/126/127) ends the session `FAILED` at once as `gate_error` ("gate misconfigured"), without counting toward the consecutive-failure abort.
50
50
 
51
51
  ### Rule 4: Progressive `--help` (JIT Schema Disclosure)
@@ -71,7 +71,7 @@ Reviewers and the Team Leader cite the failing line when they reject a change.
71
71
  - All `edit` and `bash` paths are canonicalized (`Path.resolve()`) and confined to `workspace_root`.
72
72
  - Sensitive files/directories (`.env*`, `.git/config`, `.git/hooks`, `~/.ssh`, `.tether/`) are deterministically protected from mutation or read exfiltration.
73
73
  - `bash` subprocesses get an allowlisted environment only (secrets with any name are dropped; opt-in via `TETHER_ENV_PASSTHROUGH`). `web fetch` blocks loopback/private/metadata addresses (SSRF guard). `SandboxPolicy(network=False)` / `--no-network` adds `--unshare-net` (fails closed without `bwrap`; children can only narrow it).
74
- - On Linux, `bash` automatically uses **`bwrap` (Bubblewrap) / `unshare`** when available (read-only `/`, hidden `/home` and `/var/tmp` tmpfs (existing `PATH` dirs under them, e.g. `~/.local/bin`, are re-bound read-only so `uv`/`node` still run, #76), `/run` unix sockets such as `docker.sock` masked, read-write bind on `workspace_root` with secret-looking files at any depth (`.env*`, `*.pem`, `*.key`, ...), `.tether/`, `.git/config` and `.git/hooks` masked at the mount level, `PR_SET_NO_NEW_PRIVS`), falling back to a strict deterministic command allowlist/denylist (`rm -rf /`, `sudo`, `dd`, `mkfs`, `chmod 777`, etc. blocked).
74
+ - On Linux, `bash` automatically uses **`bwrap` (Bubblewrap) / `unshare`** when available (read-only `/`, hidden `/home` and `/var/tmp` tmpfs (existing `PATH` dirs under them, e.g. `~/.local/bin`, are re-bound read-only so `uv`/`node` still run, #76), `/run` unix sockets such as `docker.sock` masked, read-write bind on `workspace_root` with secret-looking files at any depth (`.env*`, `*.pem`, `*.key`, ...), `.tether/`, `.git/config` and `.git/hooks` masked at the mount level, `PR_SET_NO_NEW_PRIVS`), falling back to a strict deterministic command allowlist/denylist (path, `..` and `.env` text rules run only in this fallback, the mounts enforce them under `bwrap`, #125; `.env.example`-style templates are not secrets) (`rm -rf /`, `sudo`, `dd`, `mkfs`, `chmod 777`, etc. blocked).
75
75
  - Sub-agents spawned via `agent spawn` strictly inherit or narrow their parent session's `SandboxPolicy` (no privilege escalation).
76
76
 
77
77
  ### Rule 7: Zero Framework Bloat
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tether-sdk
3
- Version: 0.2.5
3
+ Version: 0.2.6
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
@@ -4,8 +4,8 @@ Not part of the `tether` package, not run in CI. Use it to onboard a new model o
4
4
  release for regressions that deterministic tests cannot see (unit, golden and e2e tests stay the
5
5
  gate; see `docs/testing.md`).
6
6
 
7
- Seven tasks in `tasks/<name>/` (workspace in `ws/`, goal in `goal.txt`, hidden tests in `hidden/`):
8
- `focus` (one exact edit in a distractor-heavy repo, scope-checked), `code-simple`, `code-medium`, `code-hard` (graded by hidden unit tests; editing the visible tests
7
+ Nine tasks in `tasks/<name>/` (workspace in `ws/`, goal in `goal.txt`, hidden tests in `hidden/`):
8
+ `focus` (one exact edit in a distractor-heavy repo, scope-checked), `plan` (an implementation plan, checked for named files, failure handling and tests), `codereview` (eight seeded bugs to find, no edits), `code-simple`, `code-medium`, `code-hard` (graded by hidden unit tests; editing the visible tests
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
 
@@ -135,8 +135,49 @@ def _focus(task, ws, result):
135
135
  return r
136
136
 
137
137
 
138
+ def _steps(t):
139
+ return len(re.findall(r"^\s*\d+[.)]", t, re.M))
140
+
141
+
142
+ def _plan(_task, _ws, result):
143
+ t = result or ""
144
+ c = {
145
+ "6-10 numbered steps": 6 <= _steps(t) <= 10,
146
+ "names client.py": "client.py" in t,
147
+ "names config.py": "config.py" in t,
148
+ "names get/post": bool(re.search(r"\bget\b", t)) and bool(re.search(r"\bpost\b", t)),
149
+ "backoff+jitter": bool(re.search(r"exponential", t, re.I)) and bool(re.search(r"jitter", t, re.I)),
150
+ "retry settings": bool(re.search(r"max[_ -]?(retries|attempts)|retry[_ -]?(limit|count)", t, re.I)) and bool(re.search(r"base[_ -]?(delay|backoff)|backoff[_ -]?(base|factor)", t, re.I)),
151
+ "429 and 5xx": "429" in t and bool(re.search(r"5xx|50\d", t)),
152
+ "not retried (4xx)": bool(re.search(r"4xx|not retr|never retr|do not retry|don't retry|non-retry", t, re.I)),
153
+ "Retry-After": bool(re.search(r"retry-after", t, re.I)),
154
+ "tests without network": bool(re.search(r"mock|fake|stub|patch|monkeypatch|inject", t, re.I)) and bool(re.search(r"test", t, re.I)),
155
+ "idempotency of post": bool(re.search(r"idempot|post.{0,60}(retry|retri)|(retry|retri).{0,60}post", t, re.I)),
156
+ }
157
+ return {"score": round(sum(c.values()) / len(c), 3), "pass": sum(c.values()) >= 9, "detail": {"missed": [k for k, v in c.items() if not v]}}
158
+
159
+
160
+ BUGS = {
161
+ "page off-by-one": r"page.{0,200}(off.by.one|number\s*-\s*1|\(number - 1\)|1-based|zero|skips? the first)|(off.by.one|1-based).{0,200}page",
162
+ "mutable default": r"mutable default|tags=\[\]|default (argument|arg|list)|shared (between|across)",
163
+ "float money": r"float.{0,120}(money|price|currenc|rounding|precision|decimal)|decimal",
164
+ "float equality": r"(==|equal).{0,100}(float|0\.0)|is_free|float.{0,60}(compar|equal)|isclose",
165
+ "sql injection": r"sql.?inject|parameteri[sz]|placeholder|string concat|bind (var|param)",
166
+ "file not closed": r"(not|never) clos|resource leak|context manager|with open|leak",
167
+ "keyerror/missing": r"keyerror|missing (key|item|name)|\.get\(|unknown name|not in (the )?(stock|dict)",
168
+ "split malformed": r"split.{0,100}(more than|exactly|two|malformed|blank|empty|valueerror)|blank line|malformed|empty line|valueerror",
169
+ }
170
+
171
+
172
+ def _codereview(_task, _ws, result):
173
+ t = result or ""
174
+ hit = {k: bool(re.search(v, t, re.I | re.S)) for k, v in BUGS.items()}
175
+ must = ["page off-by-one", "mutable default", "sql injection", "file not closed", "keyerror/missing"]
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
+
178
+
138
179
  def grade(task, ws, result):
139
- fn = {"qa": _qa, "content": _content, "review": _review, "focus": _focus}.get(task, _code)
180
+ fn = {"qa": _qa, "content": _content, "review": _review, "focus": _focus, "plan": _plan, "codereview": _codereview}.get(task, _code)
140
181
  try:
141
182
  return fn(task, ws, result)
142
183
  except Exception as e: # grader bug must never look like a model result
@@ -0,0 +1,25 @@
1
+ # Harness-Bench adapter (pilot, not yet run end to end)
2
+
3
+ [Harness-Bench](https://github.com/Qihoo360/harness-bench) ([paper](https://arxiv.org/abs/2605.27922)) scores
4
+ whole harnesses on 274 offline workflow tasks (plus 26 multimodal) with hidden oracles. This directory holds
5
+ the patch that registers Tether as an eighth harness. It is a way to benchmark, not a Tether feature.
6
+
7
+ `tether-adapter.patch` applies to harness-bench commit `b8d89634c6f445fcc68ceb6744a59bef6cdf4863`:
8
+
9
+ ```bash
10
+ git clone https://github.com/Qihoo360/harness-bench && cd harness-bench
11
+ git apply /path/to/tether/bench/harness-bench/tether-adapter.patch
12
+ uv sync --extra dev
13
+ export PYTHONPATH=$PWD/src:$PWD/tools:$PWD/tools/task_maintenance
14
+ python tools/run_benchmark.py --base-url "$URL" --model "$MODEL" --harness tether \
15
+ --tasks 001-meeting-summary --workers 1 --run-root /tmp/hb-run
16
+ ```
17
+
18
+ What the patch adds: `docker/harnesses/tether/` (their toolchain image plus `tether-sdk`, an entrypoint that runs
19
+ `tether run --tools bash,edit` with state kept outside the graded workspace), a `tether` `HarnessSpec`, and a
20
+ `providers.yaml` renderer.
21
+
22
+ Status (2026-10-23): image builds, `tether run` starts inside the trial container and reaches `ProviderError ...
23
+ ConnectError`, because on the machine used containers cannot reach host ports (`host.docker.internal`, where the
24
+ per-trial model proxy listens). The host firewall must allow the docker bridge to the proxy's ports. No task has
25
+ been scored yet.
@@ -0,0 +1,134 @@
1
+ diff --git a/docker/harnesses/tether/Dockerfile b/docker/harnesses/tether/Dockerfile
2
+ new file mode 100644
3
+ index 0000000..d9f3e28
4
+ --- /dev/null
5
+ +++ b/docker/harnesses/tether/Dockerfile
6
+ @@ -0,0 +1,45 @@
7
+ +FROM python:3.12-slim-bookworm@sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b AS task-toolchain
8
+ +
9
+ +ARG PIP_INDEX_URL
10
+ +ARG PIP_EXTRA_INDEX_URL
11
+ +ARG PIP_TRUSTED_HOST
12
+ +COPY docker/openclaw/requirements.lock /tmp/harnessbench-requirements.lock
13
+ +RUN python -m pip install --disable-pip-version-check --no-cache-dir \
14
+ + --requirement /tmp/harnessbench-requirements.lock \
15
+ + && rm /tmp/harnessbench-requirements.lock
16
+ +
17
+ +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
18
+ +
19
+ +ARG TETHER_VERSION=0.2.5
20
+ +ARG PYPI_INDEX_URL=https://pypi.org/simple
21
+ +RUN apt-get update \
22
+ + && apt-get install -y --no-install-recommends bash git ripgrep sqlite3 \
23
+ + && rm -rf /var/lib/apt/lists/*
24
+ +
25
+ +
26
+ +
27
+ +RUN useradd --create-home --uid 1000 harness
28
+ +COPY --from=task-toolchain /usr/local/ /usr/local/
29
+ +COPY docker/openclaw/verify_toolchain.py /opt/harnessbench/verify_toolchain.py
30
+ +COPY docker/harnesses/tether/entrypoint.sh /opt/harness/bin/run
31
+ +RUN ln -sf /usr/local/bin/python3 /usr/bin/python3 \
32
+ + && python /opt/harnessbench/verify_toolchain.py --python-only \
33
+ + && python -m venv /opt/tether \
34
+ + && /opt/tether/bin/python -m pip install --no-cache-dir \
35
+ + --index-url "${PYPI_INDEX_URL}" "tether-sdk==${TETHER_VERSION}" \
36
+ + && chmod 0755 /opt/harness/bin/run
37
+ +
38
+ +ENV PATH="/opt/harnessbench/frontend-bin:/usr/local/bin:/opt/tether/bin:${PATH}" \
39
+ + NANOBOT_STREAM_IDLE_TIMEOUT_S=7200 \
40
+ + NANOBOT_OPENAI_COMPAT_TIMEOUT_S=7200 \
41
+ + NANOBOT_LLM_TIMEOUT_S=0
42
+ +
43
+ +# BEGIN NANOBOT LOGIN-SHELL PATH
44
+ +RUN printf '%s\n' \
45
+ + 'export PATH="/usr/local/bin:/opt/tether/bin:$PATH"' \
46
+ + > /etc/profile.d/harnessbench-path.sh \
47
+ + && chmod 0644 /etc/profile.d/harnessbench-path.sh
48
+ +# END NANOBOT LOGIN-SHELL PATH
49
+ +
50
+ +USER 1000:1000
51
+ +ENTRYPOINT ["/opt/harness/bin/run"]
52
+ diff --git a/docker/harnesses/tether/entrypoint.sh b/docker/harnesses/tether/entrypoint.sh
53
+ new file mode 100644
54
+ index 0000000..105cee6
55
+ --- /dev/null
56
+ +++ b/docker/harnesses/tether/entrypoint.sh
57
+ @@ -0,0 +1,38 @@
58
+ +#!/usr/bin/env bash
59
+ +set -euo pipefail
60
+ +
61
+ +: "${HARNESSBENCH_PROMPT:?}"
62
+ +: "${HARNESSBENCH_CONFIG:?}"
63
+ +: "${HARNESSBENCH_WORKSPACE:?}"
64
+ +: "${HARNESSBENCH_STATE:?}"
65
+ +: "${HARNESSBENCH_TRACE:?}"
66
+ +
67
+ +export HOME="${HARNESSBENCH_STATE}/home"
68
+ +mkdir -p "${HOME}" "${HARNESSBENCH_TRACE}"
69
+ +runtime_config="${HARNESSBENCH_STATE}/providers.yaml"
70
+ +cp "${HARNESSBENCH_CONFIG}" "${runtime_config}"
71
+ +# The render step writes `# model: <id>` and `# turns: <n>` on the first lines.
72
+ +model="$(sed -n 's/^# model: //p' "${runtime_config}" | head -1)"
73
+ +turns="$(sed -n 's/^# turns: //p' "${runtime_config}" | head -1)"
74
+ +prompt="$(<"${HARNESSBENCH_PROMPT}")"
75
+ +
76
+ +export TETHER_PROVIDERS="${runtime_config}"
77
+ +export VLLM_API_KEY="${VLLM_API_KEY:-local-vllm-no-auth}" # placeholder; the trial proxy injects the real key
78
+ +export TETHER_DB="${HARNESSBENCH_STATE}/tether.db" # state stays outside the graded workspace
79
+ +export TETHER_ENV_PASSTHROUGH="PATH,NODE_PATH,GIT_CONFIG_GLOBAL"
80
+ +
81
+ +set +e
82
+ +cd "${HARNESSBENCH_WORKSPACE}"
83
+ +tether run \
84
+ + --model "harnessbench/${model}" \
85
+ + --tools bash,edit \
86
+ + --max-steps "${turns:-150}" \
87
+ + "${prompt}" \
88
+ + > >(tee "${HARNESSBENCH_TRACE}/events.log") \
89
+ + 2> >(tee "${HARNESSBENCH_TRACE}/stderr.log" >&2)
90
+ +status=$?
91
+ +set -e
92
+ +
93
+ +[[ -f "${TETHER_DB}" ]] && cp "${TETHER_DB}" "${HARNESSBENCH_TRACE}/tether.db"
94
+ +chmod -R a+rX "${HARNESSBENCH_WORKSPACE}" "${HARNESSBENCH_TRACE}" 2>/dev/null || true
95
+ +exit "${status}"
96
+ diff --git a/tools/harness_runtime.py b/tools/harness_runtime.py
97
+ index c57ba22..e8763dc 100644
98
+ --- a/tools/harness_runtime.py
99
+ +++ b/tools/harness_runtime.py
100
+ @@ -67,6 +67,14 @@ SPECS = {
101
+ config_name="config.json",
102
+ docker_context=HARNESS_DOCKER_DIR / "nanobot",
103
+ ),
104
+ + "tether": HarnessSpec(
105
+ + harness_id="tether",
106
+ + version="0.2.5",
107
+ + image="harnessbench/tether:0.2.5",
108
+ + protocol="chat_completions",
109
+ + config_name="providers.yaml",
110
+ + docker_context=HARNESS_DOCKER_DIR / "tether",
111
+ + ),
112
+ "goose": HarnessSpec(
113
+ harness_id="goose",
114
+ version="1.44.0",
115
+ @@ -291,6 +299,19 @@ def render_harness_config(
116
+ }
117
+ return json.dumps(config, ensure_ascii=False, indent=2) + "\n"
118
+
119
+ + if spec.harness_id == "tether":
120
+ + entry = {
121
+ + "kind": "openai",
122
+ + "base_url": base_url,
123
+ + "api_key_env": "VLLM_API_KEY",
124
+ + "context_tokens": context_window,
125
+ + "fold_after": 8,
126
+ + "timeout": 1800,
127
+ + "params": {"temperature": temperature, "max_tokens": max_tokens},
128
+ + }
129
+ + header = f"# model: {model}\n# turns: {max_agent_turns}\n"
130
+ + return header + yaml.safe_dump({"harnessbench": entry}, sort_keys=False)
131
+ +
132
+ if spec.harness_id == "goose":
133
+ config = {
134
+ "provider": "openai",
@@ -20,6 +20,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
20
20
  TASK_CFG = { # tether tools / step cap / wall timeout
21
21
  "code-simple": ("edit,bash", 30, 600), "code-medium": ("edit,bash", 40, 900), "code-hard": ("edit,bash", 50, 1200),
22
22
  "focus": ("edit,bash,mem", 30, 600),
23
+ "plan": ("edit,bash", 20, 420), "codereview": ("edit,bash", 20, 420),
23
24
  "qa": ("", 10, 300), "content": ("", 10, 300), "review": ("edit", 15, 420),
24
25
  }
25
26
 
@@ -0,0 +1 @@
1
+ Do not edit any file. Review inventory.py. Call done with a numbered list of the real defects you find: for each, name the function, say what is wrong and give the one-line fix. Report only real bugs, no style remarks.
@@ -0,0 +1,40 @@
1
+ """Inventory helpers for a small shop."""
2
+ import sqlite3
3
+
4
+
5
+ def page(items, number, size=10):
6
+ """Return page `number` (1-based) of `items`."""
7
+ start = number * size
8
+ return items[start:start + size]
9
+
10
+
11
+ def add_tag(item, tag, tags=[]):
12
+ tags.append(tag)
13
+ item["tags"] = tags
14
+ return item
15
+
16
+
17
+ def total_price(lines):
18
+ total = 0.0
19
+ for line in lines:
20
+ total += line["price"] * line["qty"]
21
+ return total
22
+
23
+
24
+ def is_free(price):
25
+ return price == 0.0
26
+
27
+
28
+ def find_by_name(conn, name):
29
+ cur = conn.execute("SELECT id, name FROM items WHERE name = '" + name + "'")
30
+ return cur.fetchall()
31
+
32
+
33
+ def load_stock(path):
34
+ f = open(path)
35
+ data = f.read()
36
+ return dict(line.split(",") for line in data.splitlines())
37
+
38
+
39
+ def stock_of(stock, name):
40
+ return int(stock[name])
@@ -0,0 +1 @@
1
+ Do not edit any file. Read svc/client.py and svc/config.py, then call done with an implementation plan (a numbered list of 6 to 10 steps) for adding client-side retries with exponential backoff and jitter to the HTTP client. The plan must name the exact files and functions to change, say how the retry limit and base delay become settings, say which failures are retried (for example HTTP 429 and 5xx, timeouts) and which are not, say how Retry-After is honoured, and say how it will be tested without a real network. Plan only, no code.
@@ -0,0 +1,3 @@
1
+ # svc
2
+
3
+ Tiny service layer. All network calls go through `svc/client.py` (`get`, `post`). Settings live in `svc/config.py`. Tests live in `tests/` (none for the client yet).
@@ -0,0 +1,18 @@
1
+ """HTTP client used by every service call."""
2
+ import json
3
+ import urllib.request
4
+
5
+ from svc.config import BASE_URL, TIMEOUT
6
+
7
+
8
+ def get(path):
9
+ with urllib.request.urlopen(BASE_URL + path, timeout=TIMEOUT) as resp:
10
+ return json.loads(resp.read())
11
+
12
+
13
+ def post(path, payload):
14
+ req = urllib.request.Request(
15
+ BASE_URL + path, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
16
+ )
17
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
18
+ return json.loads(resp.read())
@@ -0,0 +1,3 @@
1
+ """Settings."""
2
+ BASE_URL = "https://api.example.internal"
3
+ TIMEOUT = 5
@@ -1,5 +1,16 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.2.6 (2026-10-23)
4
+
5
+ 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.
6
+
7
+ ### Fixes
8
+
9
+ - CLI: `tether query` now says why nothing matched (#93): `Error: unknown session` with exit code 1 for a wrong id, `No chunks are indexed for <id> yet` for an empty session, and the number of indexed chunks otherwise. `search_chunks` also tries a prefix match (`loop*`) after the AND and OR matches, so `loop` finds `looping`.
10
+ - Store: `SQLiteStore(":memory:")` no longer creates a file literally named `:memory:` in the working directory (#77). The path now maps to a temporary file outside the workspace, so every store method still shares one database.
11
+ - Sandbox: with `bwrap` active, `bash` no longer refuses commands by text for `..`, absolute paths outside the workspace, or `.env`/`.tether`/`.git/config` mentions (#125). The kernel mounts already enforce them (read-only root, hidden `$HOME`, masked secrets), so the text rules only produced false refusals such as `ls ~/..` or `cat /etc/hosts`. They remain the fallback when `bwrap` is missing, and `sudo`, `su`, disk tools, `rm -rf /`, `chmod 777`, fork bombs and `~/.ssh` stay refused in both modes.
12
+ - Sandbox: `.env.example`, `.env.sample`, `.env.template` and `.env.dist` are readable and writable; real `.env`, `.env.local`, `.env.production` stay masked and protected. Templates hold no secrets.
13
+
3
14
  ## v0.2.5 (2026-10-23)
4
15
 
5
16
  Found by benchmarking the same models inside Tether and inside a plain tool loop (7 tasks, 10+ models): model idioms made the single-`act` grammar fail invisibly, and loops or oversized prompts burned steps and tokens. Closes #76, #81, #86, #89, #92 and the shell-idiom bugs #109, #110.
@@ -46,6 +57,8 @@ Dogfooding fixes: the four milestone bugs (#74 #75 #85 #88) plus the friction fo
46
57
 
47
58
  ### Fixes
48
59
 
60
+ - Harness: a session that only reads gets a deterministic hint (#127). After 12 consecutive read-only steps with no change at all, then every 8 more, the observation ends with `[NUDGE] No file changes in N steps. Make your edit now, or call `done` with what you found.` (0 static prompt tokens). Before, the quiet nudge started only after a first change, so a model that never edited (nemotron-3-ultra and glm-5.3 on scoped briefs: 40 steps, 150k to 215k prompt tokens, no change) was never told.
61
+ - Harness: `awk`/`gawk`/`mawk` count as read-only (unless they print to a file, pipe to a shell, call `system()` or edit in place) and as range-shift reads of one file, so the #92 stall guard sees `awk 'NR>=a && NR<=b' f` loops. `NR>=5` used to look like a redirect, also inside compound commands (`grep ...; awk 'NR>=5' f`), which silently turned the nudges off.
49
62
  - Store: a `RUNNING` session whose worker pid no longer exists is flipped to `FAILED` (`Worker died (pid N) before finishing`) when read via `get_session`/`list_sessions`, so `status`, `list`, `wait` and `read` no longer report a killed worker as running forever (#85). A pid owned by another user counts as alive.
50
63
  - Providers: `429` retries wait `Retry-After` (else 10s doubling, capped 30s) and get up to 5 attempts instead of 3 quick ones, so rate-limited hosted models (NVIDIA free tier) no longer fail the session after 3 quick attempts.
51
64
  - Providers: each `post_with_retry` attempt is now bounded by a total `asyncio.wait_for(timeout)` deadline, so a trickling/stalled response cannot hang past the configured timeout; the deadline is retried like other transient errors and shows as `TimeoutError` (vs `ReadTimeout`) in the final `ProviderError` (#88).
@@ -85,7 +85,7 @@ trigger it, script the reply; if infrastructure must fail, add a fault to the pr
85
85
 
86
86
  ## Model conformance bench (developers only)
87
87
 
88
- `bench/` holds seven tasks (the `focus` task checks that an exact single-file goal is not lost in a distractor-heavy repo) with hidden-test graders and three runners (Tether, a plain tool loop,
88
+ `bench/` holds nine tasks (the `focus` task checks that an exact single-file goal is not lost in a distractor-heavy repo) with hidden-test graders and three runners (Tether, a plain tool loop,
89
89
  Claude Code) so the same model can be compared across harnesses and releases. It needs real
90
90
  models, so it is not in CI and not in the package: use it when onboarding a model or before a
91
91
  release to see friction that scripted tests cannot (the shell-wrapper, bare-command and
@@ -13,7 +13,7 @@ Tether never relies on system prompt instructions ("please do not read `.env` or
13
13
  ## Four Layers of Deterministic Defense
14
14
 
15
15
  ### 1. Canonical Path Confinement (`resolve_safe_path`)
16
- Every path passed to `edit` or referenced in `bash` is canonicalized via `Path.resolve()` and verified to reside inside `workspace_root`. Traversal attempts (`../../etc/passwd`) raise `SandboxViolationError` before touching the filesystem.
16
+ Every path passed to `edit` or referenced in `bash` is canonicalized via `Path.resolve()` and verified to reside inside `workspace_root`. Traversal attempts (`../../etc/passwd`) raise `SandboxViolationError` before touching the filesystem. For `bash`, the kernel jail (below) is the boundary when `bwrap` is available: path and `.env` text checks on the command line run only in the no-`bwrap` fallback, so harmless commands such as `ls ~/..` are not refused for how they look. The cheap red lines (`sudo`, `su`, disk tools, `rm -rf /`, `chmod 777`, fork bombs, `~/.ssh`) are checked in both modes. `.env.example`, `.env.sample`, `.env.template` and `.env.dist` are not treated as secrets.
17
17
 
18
18
  ### 2. Protected Sensitive Paths
19
19
  Even within `workspace_root`, Tether deterministically blocks access to sensitive files and directories:
File without changes
@@ -66,3 +66,20 @@ def test_budget_holds_with_a_wide_fold_window(tether: Tether, chaos) -> None:
66
66
  assert d["rc"] == 0
67
67
  for req in chaos.requests:
68
68
  assert _size(req) <= 1500 * 1.3, f"prompt grew past the budget: ~{_size(req)} tokens"
69
+
70
+
71
+ def test_awk_ranges_of_one_file_count_as_range_shift_reads(tether: Tether, chaos) -> None:
72
+ (tether.ws / "big.py").write_text("x = 1\n" * 300)
73
+ reads = [f"bash awk 'NR>={i * 20 + 1} && NR<={i * 20 + 20}' big.py" for i in range(12)]
74
+ chaos.script(*reads)
75
+ d = tether.run("explore one file", "--tools", "bash", "--max-steps", "30")
76
+ assert any("reads of `big.py`" in o for o in tether.tool_outputs(d["id"]))
77
+
78
+
79
+ def test_explore_nudge_after_twelve_reads_without_a_change(tether: Tether, chaos) -> None:
80
+ for i in range(14):
81
+ (tether.ws / f"f{i}.txt").write_text(f"file {i}\n")
82
+ chaos.script(*[f"bash cat f{i}.txt" for i in range(14)], ("done", "ok"))
83
+ d = tether.run("read everything", "--tools", "bash", "--max-steps", "30")
84
+ outs = tether.tool_outputs(d["id"])
85
+ assert sum("[NUDGE] No file changes in 12 steps" in o for o in outs) == 1, outs[-3:]
@@ -3,6 +3,10 @@ result never depends on the model's obedience; tether alone must say no."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import shutil
7
+
8
+ import pytest
9
+
6
10
  from e2e.conftest import Tether
7
11
 
8
12
 
@@ -60,3 +64,14 @@ def test_verify_gate_can_run_a_tool_installed_under_home(tether: Tether, chaos)
60
64
  chaos.script(("done", "ok"))
61
65
  d = tether.run("x", "--verify", "gatetool", "--tools", "bash")
62
66
  assert d["rc"] == 0, d
67
+
68
+
69
+ @pytest.mark.skipif(shutil.which("bwrap") is None, reason="needs bwrap")
70
+ def test_bash_reads_env_template_but_not_the_secret(tether: Tether, chaos) -> None:
71
+ (tether.ws / ".env").write_text("TOKEN=hunter2")
72
+ (tether.ws / ".env.example").write_text("TOKEN=\n")
73
+ chaos.script("bash cat .env.example", "bash cat .env", ("done", "ok"))
74
+ r = tether.run("read env files", "--tools", "bash")
75
+ template, secret = tether.tool_outputs(r["id"])[:2]
76
+ assert "TOKEN=" in template and "Sandbox violation" not in template, template
77
+ assert "hunter2" not in secret, secret # masked by the kernel mount, not by a text rule
@@ -156,3 +156,19 @@ async def test_stop_terminates_a_running_worker(
156
156
  assert await _async_main(["stop", rec.id, "--json", "--db", db_arg]) == 0
157
157
  assert '"kind": "stopped"' in capsys.readouterr().out
158
158
  assert len(killed) == 1 # a finished session is left alone
159
+
160
+
161
+ async def test_query_reports_why_nothing_matched(
162
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
163
+ ) -> None:
164
+ db_arg = str(tmp_path / "tether.db")
165
+ store = SQLiteStore(db_path=db_arg)
166
+ rec = store.create_session("g")
167
+ assert await _async_main(["query", "ses_nope", "x", "--db", db_arg]) == 1
168
+ assert "unknown session `ses_nope`" in capsys.readouterr().err
169
+ assert await _async_main(["query", rec.id, "anything", "--db", db_arg]) == 0
170
+ assert "No chunks are indexed for" in capsys.readouterr().out
171
+ store.add_observation(rec.id, step=1, cmd="bash x", raw_output="alpha beta gamma")
172
+ assert await _async_main(["query", rec.id, "nonexistent_xyz", "--db", db_arg]) == 0
173
+ out = capsys.readouterr().out
174
+ assert "1 chunks indexed" in out and "nonexistent_xyz" in out
@@ -11,7 +11,7 @@ import pytest
11
11
  from tests.conftest import FakeScriptedProvider
12
12
  from tether import ActionCall, Harness, ModelResponse, register_provider, tool
13
13
  from tether.decorator import ToolSpec
14
- from tether.harness import detect_cycle, read_target, spawn_detached_worker
14
+ from tether.harness import detect_cycle, is_quiet, read_target, spawn_detached_worker
15
15
  from tether.store import SQLiteStore
16
16
 
17
17
 
@@ -662,3 +662,59 @@ async def test_context_overflow_warns_once_on_stderr(
662
662
  err = capsys.readouterr().err
663
663
  assert err.count("over --context-tokens 20") == 1
664
664
  assert "raise the budget" in err
665
+
666
+
667
+ async def test_explore_nudge_when_nothing_changes(tmp_path: Path) -> None:
668
+ run = await _run_commands(tmp_path, [f"bash cat f{i}" for i in range(14)]) # no change at all
669
+ obs = run.provider.store.list_observations(run.session.id)
670
+ steps = [o.step for o in obs.values() if "[NUDGE] No file changes in 12 steps" in o.raw_output]
671
+ assert steps == [12]
672
+
673
+
674
+ async def test_explore_nudge_repeats_while_the_model_only_reads(
675
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
676
+ ) -> None:
677
+ monkeypatch.setattr("tether.harness.EXPLORE_NUDGE_STEPS", 2)
678
+ monkeypatch.setattr("tether.harness.EXPLORE_NUDGE_EVERY", 3)
679
+ run = await _run_commands(tmp_path, [f"bash cat f{i}" for i in range(9)])
680
+ obs = run.provider.store.list_observations(run.session.id)
681
+ assert [o.step for o in obs.values() if "[NUDGE] No file changes" in o.raw_output] == [2, 5, 8]
682
+
683
+
684
+ async def test_explore_nudge_is_off_after_a_change(
685
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
686
+ ) -> None:
687
+ monkeypatch.setattr("tether.harness.EXPLORE_NUDGE_STEPS", 2)
688
+ run = await _run_commands(tmp_path, ["ping a.com", "bash cat f0", "bash cat f1", "bash cat f2"])
689
+ obs = run.provider.store.list_observations(run.session.id)
690
+ assert not [o for o in obs.values() if "[NUDGE] No file changes in" in o.raw_output]
691
+
692
+
693
+ @pytest.mark.parametrize(
694
+ ("cmd", "quiet"),
695
+ [
696
+ ("awk 'NR>=470 && NR<=500 {printf \"%d:%s\\n\", NR, $0}' tether/cli.py", True),
697
+ ("bash awk '/a/{n++} END{print n}' README.md", True),
698
+ ("awk '{print $1 > \"out.txt\"}' f", False),
699
+ ("awk 'BEGIN{system(\"rm x\")}'", False),
700
+ ("awk '{print}' f | tee g", False),
701
+ ("gawk -i inplace '{print}' f", False),
702
+ ],
703
+ )
704
+ def test_awk_is_quiet_unless_it_writes(cmd: str, quiet: bool) -> None:
705
+ assert is_quiet(cmd) is quiet
706
+ assert (read_target(cmd) is not None) is quiet
707
+
708
+
709
+ @pytest.mark.parametrize(
710
+ ("cmd", "quiet"),
711
+ [
712
+ ("grep -n x f; awk 'NR>=820 && NR<=856' f", True), # `>=` is a comparison, not a redirect
713
+ ("cat f | awk 'NR>5'", True),
714
+ ("echo hi > out.txt", False),
715
+ ("cat a >> b", False),
716
+ ("ls >out", False),
717
+ ],
718
+ )
719
+ def test_comparison_operators_are_not_redirects(cmd: str, quiet: bool) -> None:
720
+ assert is_quiet(cmd) is quiet
@@ -100,7 +100,7 @@ async def test_web_fetch_blocks_private_addresses(
100
100
  ],
101
101
  )
102
102
  def test_program_args_are_not_paths(cmd: str, tmp_path: Path) -> None:
103
- SandboxPolicy(workspace_root=tmp_path).validate_bash_command(cmd)
103
+ SandboxPolicy(workspace_root=tmp_path, use_kernel_sandbox=False).validate_bash_command(cmd)
104
104
 
105
105
 
106
106
  @pytest.mark.parametrize(
@@ -108,10 +108,61 @@ def test_program_args_are_not_paths(cmd: str, tmp_path: Path) -> None:
108
108
  ["sed -n p /etc/hosts", "awk -f /etc/passwd x", "cat /etc/hosts", "cd . && cat /etc/hosts"],
109
109
  )
110
110
  def test_real_absolute_paths_still_blocked(cmd: str, tmp_path: Path) -> None:
111
+ with pytest.raises(SandboxViolationError): # text rules are the no-bwrap fallback
112
+ SandboxPolicy(workspace_root=tmp_path, use_kernel_sandbox=False).validate_bash_command(cmd)
113
+
114
+
115
+ @pytest.mark.parametrize(
116
+ "cmd",
117
+ ["ls ~/..", "cat /etc/hosts", "cat .tether/x", "cat .git/config"],
118
+ )
119
+ def test_kernel_sandbox_leaves_path_policing_to_the_mounts(
120
+ cmd: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
121
+ ) -> None:
122
+ monkeypatch.setattr(sb_mod, "_can_use_bwrap", lambda: True)
123
+ SandboxPolicy(workspace_root=tmp_path).validate_bash_command(cmd) # no false refusal
124
+ monkeypatch.setattr(sb_mod, "_can_use_bwrap", lambda: False)
125
+ with pytest.raises(SandboxViolationError): # same commands stay refused without bwrap
126
+ SandboxPolicy(workspace_root=tmp_path).validate_bash_command(cmd)
127
+
128
+
129
+ @pytest.mark.parametrize("cmd", ["sudo ls", "rm -rf /", "cat ~/.ssh/id_rsa", "chmod 777 x"])
130
+ def test_kernel_sandbox_keeps_the_cheap_red_lines(
131
+ cmd: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
132
+ ) -> None:
133
+ monkeypatch.setattr(sb_mod, "_can_use_bwrap", lambda: True)
111
134
  with pytest.raises(SandboxViolationError):
112
135
  SandboxPolicy(workspace_root=tmp_path).validate_bash_command(cmd)
113
136
 
114
137
 
138
+ @pytest.mark.parametrize(
139
+ ("name", "secret"),
140
+ [(".env", True), (".env.local", True), (".env.example", False), (".env.dist", False)],
141
+ )
142
+ def test_env_templates_are_readable_secrets_are_not(
143
+ name: str, secret: bool, tmp_path: Path
144
+ ) -> None:
145
+ (tmp_path / name).write_text("K=v")
146
+ sp = SandboxPolicy(workspace_root=tmp_path, use_kernel_sandbox=False)
147
+ if secret:
148
+ with pytest.raises(SandboxViolationError):
149
+ sp.resolve_safe_path(name)
150
+ with pytest.raises(SandboxViolationError):
151
+ sp.validate_bash_command(f"cat {name}")
152
+ else:
153
+ assert sp.resolve_safe_path(name).name == name
154
+ sp.validate_bash_command(f"cat {name}")
155
+
156
+
157
+ @needs_bwrap
158
+ async def test_masked_secret_and_env_example_under_real_bwrap(tmp_path: Path) -> None:
159
+ (tmp_path / ".env").write_text("TOKEN=secret")
160
+ (tmp_path / ".env.example").write_text("TOKEN=\n")
161
+ sp = SandboxPolicy(workspace_root=tmp_path)
162
+ code, out = await sp.execute_bash_with_code("cat .env.example; echo ---; cat .env")
163
+ assert "TOKEN=" in out.split("---")[0] and "secret" not in out, out
164
+
165
+
115
166
  def test_unknown_tool_name_rejected(tmp_path: Path) -> None:
116
167
  with pytest.raises(ValueError, match="bahs"):
117
168
  Harness(tools="bash,bahs", db_path=tmp_path / "t.db")
@@ -314,3 +314,26 @@ def test_reap_dead_keeps_other_users_pid(tmp_path: Path, monkeypatch: pytest.Mon
314
314
  monkeypatch.setattr("os.kill", deny)
315
315
  got = store.get_session(rec.id)
316
316
  assert got is not None and got.status == "RUNNING"
317
+
318
+
319
+ def test_count_chunks_and_prefix_fallback(tmp_path: Path) -> None:
320
+ store = SQLiteStore(db_path=tmp_path / "t.db")
321
+ rec = store.create_session("g")
322
+ assert store.count_chunks([]) == 0
323
+ assert store.count_chunks([rec.id]) == 0
324
+ store.add_observation(rec.id, step=1, cmd="bash x", raw_output="the model keeps looping here")
325
+ assert store.count_chunks([rec.id]) >= 1
326
+ hits = store.search_chunks("loop", session_ids=[rec.id]) # prefix: loop -> looping
327
+ assert hits and "looping" in hits[0].content
328
+ assert store.search_chunks("zz", session_ids=[rec.id]) == [] # too short for a prefix match
329
+
330
+
331
+ def test_memory_path_does_not_create_a_file_in_cwd(
332
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
333
+ ) -> None:
334
+ monkeypatch.chdir(tmp_path)
335
+ store = SQLiteStore(":memory:")
336
+ rec = store.create_session("g")
337
+ assert store.get_session(rec.id) is not None
338
+ assert not (tmp_path / ":memory:").exists()
339
+ assert store.db_path.parent != tmp_path