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.
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/AGENTS.md +1 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/PKG-INFO +1 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/README.md +8 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/grade.py +47 -2
- tether_sdk-0.2.7/bench/report.py +48 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/run.py +29 -4
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/architecture.md +2 -2
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/context-and-store.md +1 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/providers.md +1 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/release-notes.md +13 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_faults.py +12 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_context.py +24 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_harness.py +13 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_params.py +30 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_tether.py +1 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/__init__.py +1 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/context.py +6 -2
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/harness.py +3 -2
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/anthropic.py +3 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/base.py +17 -1
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/ollama.py +3 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/openai.py +3 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.claude/feature-context/active/.gitkeep +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.claude/feature-context/active/v0-2-bugs.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.claude/feature-context/archive/.gitkeep +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.github/workflows/ci.yml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.github/workflows/delivery.yml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.github/workflows/docs.yml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/.gitignore +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/LICENSE +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/README.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/harness-bench/README.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/harness-bench/tether-adapter.patch +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/naive_agent.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/hidden/test_hidden.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/errors.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/evaluator.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/parser.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ref/calc/tokenizer.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/errors.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/evaluator.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/parser.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/calc/tokenizer.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/tests/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-hard/ws/tests/test_calc.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/hidden/test_hidden.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/cli.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/ledger/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/ledger/parser.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/ledger/report.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/sample.csv +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/tests/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-medium/ws/tests/test_report.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/hidden/test_hidden.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/ws/tests/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/ws/tests/test_textutils.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/code-simple/ws/textutils.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/codereview/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/codereview/ws/inventory.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/content/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/content/ws/.keep +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/hidden/test_hidden.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/AGENTS.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/README.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/docs/architecture.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/docs/style.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/cli.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/fmt.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/stats.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/mini/textio.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/tests/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/focus/ws/tests/test_stats.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/README.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/svc/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/svc/client.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/plan/ws/svc/config.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/qa/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/qa/ws/.keep +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/review/goal.txt +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/bench/tasks/review/ws/paper.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/benchmarks.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/extending.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/index.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/javascripts/mathjax.js +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/agent-interface.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/harness.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/index.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/multiplexer-and-decorator.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/sandbox-and-skills.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/reference/tools.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/releasing.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/testing.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/troubleshooting.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/agent-interface.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/built-in-tools.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/cli-and-headless.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/index.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/multi-agent.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/sdk-and-custom-tools.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/security-and-sandbox.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/skills.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/docs/tutorial/workflows-and-telemetry.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/conftest.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/proxy.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_agents.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_defaults.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_lifecycle.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_limits.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_quirks.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_sandbox.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/e2e/test_surface.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/README.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/checks.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/course.yaml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/ruff.toml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/planner/SKILL.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/searcher/SKILL.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/searcher/tools.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/writer/SKILL.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/examples/course/skills/writer/tools.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/mkdocs.yml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/pyproject.toml +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/scripts/record_golden.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/skills/researcher/SKILL.md +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/conftest.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/fixtures/dialects.jsonl +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/coding_edit_failure_modes.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/coding_edit_failure_modes.snap.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/done_nudge.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/done_nudge.snap.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/read_range_pingpong.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/read_range_pingpong.snap.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/research_web.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/research_web.snap.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/shell_wrappers.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/shell_wrappers.snap.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/waiting_input_resume.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/golden/waiting_input_resume.snap.json +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_anthropic.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_base.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_declarative.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_dialects.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_ollama.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_openai.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/providers/test_retry.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_agent_interface.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_cli.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_context_config.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_decorator.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_edit_tool.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_golden.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_integration.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_misses.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_multiplexer.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_pipeline.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_release_hardening.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_retro.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_sandbox.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_security_hardening.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_skills.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_store.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/test_structured_history.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_agent.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_bash.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_done.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_edit.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_mem.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tests/tools/test_web.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/api.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/cli.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/decorator.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/mcp.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/multiplexer.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/pipeline.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/providers/declarative.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/retro.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/sandbox.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/schema.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/skills.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/store.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/__init__.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/agent.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/bash.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/done.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/edit.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/mem.py +0 -0
- {tether_sdk-0.2.6 → tether_sdk-0.2.7}/tether/tools/web.py +0 -0
- {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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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, _ =
|
|
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
|
-
|
|
103
|
-
|
|
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(),
|
|
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 (
|
|
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
|
|
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
|
|
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" *
|
|
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.
|
|
10
|
+
assert tether.__version__ == "0.2.7"
|
|
11
11
|
for symbol in tether.__all__:
|
|
12
12
|
assert hasattr(tether, symbol)
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|