tether-sdk 0.2.4__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.
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/AGENTS.md +6 -5
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/PKG-INFO +4 -2
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/README.md +3 -1
- tether_sdk-0.2.6/bench/README.md +37 -0
- tether_sdk-0.2.6/bench/grade.py +189 -0
- tether_sdk-0.2.6/bench/harness-bench/README.md +25 -0
- tether_sdk-0.2.6/bench/harness-bench/tether-adapter.patch +134 -0
- tether_sdk-0.2.6/bench/naive_agent.py +92 -0
- tether_sdk-0.2.6/bench/run.py +174 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/hidden/test_hidden.py +31 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ref/calc/__init__.py +10 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ref/calc/errors.py +2 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ref/calc/evaluator.py +44 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ref/calc/parser.py +78 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ref/calc/tokenizer.py +24 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ws/calc/__init__.py +10 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ws/calc/errors.py +2 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ws/calc/evaluator.py +42 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ws/calc/parser.py +78 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ws/calc/tokenizer.py +24 -0
- tether_sdk-0.2.6/bench/tasks/code-hard/ws/tests/test_calc.py +24 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/goal.txt +8 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/hidden/test_hidden.py +51 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/cli.py +9 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/ledger/__init__.py +0 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/ledger/parser.py +14 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/ledger/report.py +10 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/sample.csv +6 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/tests/__init__.py +0 -0
- tether_sdk-0.2.6/bench/tasks/code-medium/ws/tests/test_report.py +15 -0
- tether_sdk-0.2.6/bench/tasks/code-simple/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/code-simple/hidden/test_hidden.py +14 -0
- tether_sdk-0.2.6/bench/tasks/code-simple/ws/tests/__init__.py +0 -0
- tether_sdk-0.2.6/bench/tasks/code-simple/ws/tests/test_textutils.py +20 -0
- tether_sdk-0.2.6/bench/tasks/code-simple/ws/textutils.py +14 -0
- tether_sdk-0.2.6/bench/tasks/codereview/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/codereview/ws/inventory.py +40 -0
- tether_sdk-0.2.6/bench/tasks/content/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/content/ws/.keep +0 -0
- tether_sdk-0.2.6/bench/tasks/focus/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/focus/hidden/test_hidden.py +39 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/AGENTS.md +57 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/README.md +5 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/docs/architecture.md +80 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/docs/style.md +60 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/mini/__init__.py +1 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/mini/cli.py +23 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/mini/fmt.py +5 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/mini/stats.py +13 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/mini/textio.py +6 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/tests/__init__.py +0 -0
- tether_sdk-0.2.6/bench/tasks/focus/ws/tests/test_stats.py +11 -0
- tether_sdk-0.2.6/bench/tasks/plan/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/plan/ws/README.md +3 -0
- tether_sdk-0.2.6/bench/tasks/plan/ws/svc/__init__.py +0 -0
- tether_sdk-0.2.6/bench/tasks/plan/ws/svc/client.py +18 -0
- tether_sdk-0.2.6/bench/tasks/plan/ws/svc/config.py +3 -0
- tether_sdk-0.2.6/bench/tasks/qa/goal.txt +7 -0
- tether_sdk-0.2.6/bench/tasks/qa/ws/.keep +0 -0
- tether_sdk-0.2.6/bench/tasks/review/goal.txt +1 -0
- tether_sdk-0.2.6/bench/tasks/review/ws/paper.md +13 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/architecture.md +1 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/extending.md +13 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/agent-interface.md +5 -3
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/context-and-store.md +1 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/providers.md +34 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/release-notes.md +46 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/testing.md +9 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/troubleshooting.md +2 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/cli-and-headless.md +6 -2
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/security-and-sandbox.md +2 -2
- tether_sdk-0.2.6/e2e/__init__.py +0 -0
- tether_sdk-0.2.6/e2e/test_limits.py +85 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_quirks.py +29 -4
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_sandbox.py +27 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_surface.py +7 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/pyproject.toml +1 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/done_nudge.snap.json +5 -5
- tether_sdk-0.2.6/tests/golden/shell_wrappers.json +29 -0
- tether_sdk-0.2.6/tests/golden/shell_wrappers.snap.json +105 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_base.py +24 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_declarative.py +20 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_retry.py +53 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_agent_interface.py +33 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_cli.py +38 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_context.py +56 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_context_config.py +1 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_edit_tool.py +108 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_harness.py +134 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_multiplexer.py +52 -0
- tether_sdk-0.2.6/tests/test_params.py +127 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_release_hardening.py +5 -2
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_sandbox.py +52 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_security_hardening.py +52 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_store.py +23 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_tether.py +1 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/__init__.py +3 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/api.py +6 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/cli.py +51 -2
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/context.py +35 -3
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/harness.py +121 -5
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/mcp.py +5 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/multiplexer.py +81 -12
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/providers/__init__.py +8 -5
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/providers/anthropic.py +9 -2
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/providers/base.py +112 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/providers/declarative.py +14 -3
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/providers/ollama.py +16 -11
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/providers/openai.py +9 -3
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/sandbox.py +60 -9
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/store.py +18 -1
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/edit.py +128 -0
- tether_sdk-0.2.4/e2e/test_limits.py +0 -44
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.claude/feature-context/active/.gitkeep +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.claude/feature-context/active/v0-2-bugs.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.claude/feature-context/archive/.gitkeep +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.github/workflows/ci.yml +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.github/workflows/delivery.yml +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.github/workflows/docs.yml +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/.gitignore +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/LICENSE +0 -0
- {tether_sdk-0.2.4/e2e → tether_sdk-0.2.6/bench/tasks/code-hard/ws/tests}/__init__.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/benchmarks.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/index.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/javascripts/mathjax.js +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/harness.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/index.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/multiplexer-and-decorator.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/sandbox-and-skills.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/reference/tools.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/releasing.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/agent-interface.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/built-in-tools.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/index.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/multi-agent.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/sdk-and-custom-tools.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/skills.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/docs/tutorial/workflows-and-telemetry.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/conftest.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/proxy.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_agents.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_defaults.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_faults.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/e2e/test_lifecycle.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/README.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/checks.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/course.yaml +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/ruff.toml +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/skills/planner/SKILL.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/skills/searcher/SKILL.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/skills/searcher/tools.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/skills/writer/SKILL.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/examples/course/skills/writer/tools.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/mkdocs.yml +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/scripts/record_golden.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/skills/researcher/SKILL.md +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/__init__.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/conftest.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/fixtures/dialects.jsonl +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/coding_edit_failure_modes.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/coding_edit_failure_modes.snap.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/done_nudge.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/read_range_pingpong.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/read_range_pingpong.snap.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/research_web.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/research_web.snap.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/waiting_input_resume.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/golden/waiting_input_resume.snap.json +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/__init__.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_anthropic.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_dialects.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_ollama.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/providers/test_openai.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_decorator.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_golden.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_integration.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_misses.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_pipeline.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_retro.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_skills.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/test_structured_history.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/__init__.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/test_agent.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/test_bash.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/test_done.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/test_edit.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/test_mem.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tests/tools/test_web.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/decorator.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/pipeline.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/retro.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/schema.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/skills.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/__init__.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/agent.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/bash.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/done.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/mem.py +0 -0
- {tether_sdk-0.2.4 → tether_sdk-0.2.6}/tether/tools/web.py +0 -0
- {tether_sdk-0.2.4 → 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).
|
|
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)
|
|
@@ -62,7 +62,7 @@ Reviewers and the Team Leader cite the failing line when they reject a change.
|
|
|
62
62
|
```text
|
|
63
63
|
[obs_3 folded: `web fetch https://...` (3,420 chars) | use `mem recall obs_3` to expand]
|
|
64
64
|
```
|
|
65
|
-
- Exception: the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The 2,400-char head+tail elision still applies, and
|
|
65
|
+
- Exception: the latest read of each of the last 8 files (`edit view <p>`, `bash cat/head/tail/sed -n <p>`; paths normalized against the workspace) is not folded until `<p>` is edited (`edit write|replace`, or any other bash command naming it: `sed -i`, `tee`, redirects, `python -c`) or re-read. The 2,400-char head+tail elision still applies, and under `max_context_tokens` pressure it is folded like any other (oldest first). The `mem recall` hint is shown only when `mem` is enabled (else "re-run the command").
|
|
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)
|
|
@@ -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, `/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
|
|
@@ -85,7 +85,7 @@ Reviewers and the Team Leader cite the failing line when they reject a change.
|
|
|
85
85
|
```text
|
|
86
86
|
tether/
|
|
87
87
|
├── __init__.py # Public SDK exports: Harness, Pipeline, Session, Skill, SandboxPolicy, tool
|
|
88
|
-
├── cli.py # Headless-capable CLI: `tether run|pipeline|status|read|query|send|list|wait|stats|retro|events|misses|mcp` (`--json` + documented exit codes)
|
|
88
|
+
├── cli.py # Headless-capable CLI: `tether run|pipeline|status|read|query|send|list|wait|stop|stats|retro|events|misses|mcp` (`--json` + documented exit codes)
|
|
89
89
|
├── harness.py # Control Plane: bounded agent loop, pipelines, SQLite inbox, worker spawn
|
|
90
90
|
├── pipeline.py # Workflow Plane: declarative multi-stage YAML pipelines & model routing
|
|
91
91
|
├── api.py # Agent-facing JSON session view, exit codes, events (`--json`)
|
|
@@ -166,6 +166,7 @@ tether/
|
|
|
166
166
|
- If no provider prefix is given, defaults to `ollama/<model>`.
|
|
167
167
|
- Custom SDK transports (e.g., internal corporate gateways, custom local runners, or test harnesses) can be injected via `Harness(provider=...)` or registered under a custom `<prefix>/<model>` via `register_provider(prefix, factory)`.
|
|
168
168
|
- Declarative providers: `~/.tether/providers.yaml` (override with `TETHER_PROVIDERS`, never read from the workspace) maps a model-id prefix to a `kind` (`openai`, `ollama`, `anthropic` or `command`); `claude-cli/sonnet` uses the entry `claude-cli`. The `command` kind runs `argv` directly (never through a shell) with `{model}`, `{system}` and `{conversation}` placeholders, and reads the reply and usage from JSON paths. Built-in prefixes (`ollama`, `openai`, `anthropic`, `claude`) cannot be shadowed, keys are env var names (never values), and an invalid file raises `ValueError: providers.yaml: ...`. Implementation in `tether/providers/declarative.py`; docs in `docs/reference/providers.md`.
|
|
169
|
+
- `providers.yaml` entries may set `params` (request-body parameters for HTTP kinds, overridable per call via `--param`, `Harness(params=)`, MCP `params`, stored in `sandbox_json`, #112) and `rpm` (requests per minute cap for HTTP kinds, sliding window shared across processes via a lock file, #110).
|
|
169
170
|
- Environment variables:
|
|
170
171
|
- `OLLAMA_HOST` (default `http://localhost:11434`); `TETHER_OLLAMA_FALLBACK=1` opts in to substituting an installed model when the requested one 404s (default: fail loudly with the installed list)
|
|
171
172
|
- `OPENAI_API_KEY`, `OPENAI_BASE_URL` (default `https://api.openai.com/v1` — works with OpenRouter, vLLM, Groq, etc.)
|
|
@@ -204,7 +205,7 @@ uv run mypy tether/
|
|
|
204
205
|
2. **Async-First Design:**
|
|
205
206
|
- All model calls and I/O-bound tool executions must be `async`/`await` native, while seamlessly wrapping synchronous user `@tool` functions.
|
|
206
207
|
3. **Graceful Degradation on 9B Hallucinations:**
|
|
207
|
-
- Small models occasionally emit `cmd` with minor quirks (e.g., leading `$`, wrapping backticks, or calling a tool name directly instead of `act`). The multiplexer must normalize trivial syntax quirks deterministically before failing. This includes unescaping literal `\n`/`\t` in one-line `stdin` for `edit write`/`edit replace` and `done` (never `bash`),
|
|
208
|
+
- Small models occasionally emit `cmd` with minor quirks (e.g., leading `$`, wrapping backticks, or calling a tool name directly instead of `act`). The multiplexer must normalize trivial syntax quirks deterministically before failing. This includes unescaping literal `\n`/`\t` in one-line `stdin` for `edit write`/`edit replace` and `done` (never `bash`), turning a trailing shell heredoc in `cmd` (`edit write f <<'EOF' ... EOF`) into `stdin` (never for `bash`), unwrapping `bash -c/-lc '<cmd>'` and `sh -c` wrappers, running an unknown first word through the enabled `bash` tool (never for disabled built-ins), registering extra quirk fixers with `register_normalizer`, mapping a native tool call named other than `act` to `<name> --k 'v'` so the JIT usage answers it, inferring the `edit` verb (`edit <path>` -> view/replace/write) and accepting `apply_patch` (`*** Begin Patch` or unified-diff patches, also when a diff arrives as `edit <path>` stdin) as an `edit` verb.
|
|
208
209
|
4. **Golden traces:** behaviour the model sees (framing, folding, nudges, tool output) is locked by `tests/golden/*.snap.json`. A PR that changes it runs `UPDATE_GOLDEN=1 pytest tests/test_golden.py` and the snapshot diff is part of the review; unrelated traces must not change. See `docs/testing.md`.
|
|
209
210
|
5. **E2E scenarios:** every feature or bug fix ships with a scenario in `e2e/` (real CLI, real ollama behind a fault-injecting proxy, local only, `TETHER_E2E=1 uv run pytest e2e`). Assert on what tether sends and records, never on the model's skill; script the reply or inject the fault so the test does not depend on model potency. **Before merging any PR, run `TETHER_E2E=1 uv run pytest e2e` locally and put the result in the PR description (CI cannot run it). Every failure must be mitigated before merge: fix the code, or fix a wrong scenario, or record in the PR why it is acceptable and what follows up. A PR is not merged with an unexplained red scenario.** See `docs/testing.md`.
|
|
210
211
|
6. **Documentation Integrity:**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tether-sdk
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -265,8 +265,10 @@ $ tether stats
|
|
|
265
265
|
# 7. Post-mortem: distill friction into <=2 short rules (diff; --apply writes ## Learned Rules)
|
|
266
266
|
$ tether retro ses_8f92a1 [--apply]
|
|
267
267
|
|
|
268
|
-
# 8. Call Tether from another agent: JSON output, exit codes (0 ok, 2 needs input, 3 gate, 4 max steps, 5 error)
|
|
268
|
+
# 8. Call Tether from another agent: JSON output, exit codes (0 ok, 2 needs input, 3 gate, 4 max steps or stalled, 5 error)
|
|
269
269
|
$ tether run --json --output-schema out.json "goal"
|
|
270
|
+
$ tether stop ses_8f92a1 # terminate a running detached session
|
|
271
|
+
$ tether run --param temperature=0 --param think=false "goal" # per-call model parameters
|
|
270
272
|
$ claude mcp add tether -- tether mcp --workspace "$PWD" # MCP server (needs: pip install "tether-sdk[mcp]")
|
|
271
273
|
```
|
|
272
274
|
|
|
@@ -244,8 +244,10 @@ $ tether stats
|
|
|
244
244
|
# 7. Post-mortem: distill friction into <=2 short rules (diff; --apply writes ## Learned Rules)
|
|
245
245
|
$ tether retro ses_8f92a1 [--apply]
|
|
246
246
|
|
|
247
|
-
# 8. Call Tether from another agent: JSON output, exit codes (0 ok, 2 needs input, 3 gate, 4 max steps, 5 error)
|
|
247
|
+
# 8. Call Tether from another agent: JSON output, exit codes (0 ok, 2 needs input, 3 gate, 4 max steps or stalled, 5 error)
|
|
248
248
|
$ tether run --json --output-schema out.json "goal"
|
|
249
|
+
$ tether stop ses_8f92a1 # terminate a running detached session
|
|
250
|
+
$ tether run --param temperature=0 --param think=false "goal" # per-call model parameters
|
|
249
251
|
$ claude mcp add tether -- tether mcp --workspace "$PWD" # MCP server (needs: pip install "tether-sdk[mcp]")
|
|
250
252
|
```
|
|
251
253
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# bench: model-in-the-loop checks (developers only)
|
|
2
|
+
|
|
3
|
+
Not part of the `tether` package, not run in CI. Use it to onboard a new model or to check a
|
|
4
|
+
release for regressions that deterministic tests cannot see (unit, golden and e2e tests stay the
|
|
5
|
+
gate; see `docs/testing.md`).
|
|
6
|
+
|
|
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
|
+
scores 0), `qa`, `content`, `review` (graded by constraint counts and keyword checks, so they are
|
|
10
|
+
a smoke test for quality, not a judge).
|
|
11
|
+
|
|
12
|
+
Three runners, so a gap can be attributed:
|
|
13
|
+
|
|
14
|
+
| runner | what it is |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `tether` | the CLI under test (`--tether-bin` picks a version or a venv) |
|
|
17
|
+
| `naive` | `naive_agent.py`, a plain OpenAI-compatible tool loop (bash/read/write) with the same model |
|
|
18
|
+
| `claude` | Claude Code in print mode, as a frontier baseline (`--models cc/sonnet`) |
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export NVIDIA_API_KEY=... # only for nvidia/* models; OLLAMA_HOST defaults to :11435
|
|
22
|
+
uv run python bench/run.py --runners tether,naive --models ollama/qwen3.5:9b \
|
|
23
|
+
--tasks code-simple,qa --trials 2 --out /tmp/bench
|
|
24
|
+
uv run python bench/run.py --runners tether --tether-bin /path/to/venv/bin/tether \
|
|
25
|
+
--models ollama/qwen3.5:9b --param think=false --out /tmp/bench-v0.2.3
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Each run lands in `<out>/<runner>__<model>__<task>__t<k>/` with the workspace, stdout, the
|
|
29
|
+
session DB (`t.db`) and `done.json`; `results.jsonl` collects them. Re-running skips finished
|
|
30
|
+
cells; `--redo-infra` reruns cells that failed on provider errors (429, 503, timeouts).
|
|
31
|
+
|
|
32
|
+
Read results with the friction events (`syntax_error`, `loop_stall`, `nudge`, ...) next to the
|
|
33
|
+
score: a low score with many events is a harness problem, a low score with none is the model.
|
|
34
|
+
Rate limits (NVIDIA: 40 requests/min) invalidate results silently; set `rpm:` in `providers.yaml`
|
|
35
|
+
and keep parallelism low (`--par nvidia=2`).
|
|
36
|
+
|
|
37
|
+
Caveats: one run per cell is noisy (about 0.1); the scores are regex and hidden-test based.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Deterministic graders. grade(task, ws, result_text) -> {"score": 0..1, "pass": bool, "detail": {...}}"""
|
|
2
|
+
import ast
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
13
|
+
TASKS = os.path.join(HERE, "tasks")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tree_hash(path):
|
|
17
|
+
h = hashlib.sha256()
|
|
18
|
+
for root, _, files in sorted(os.walk(path)):
|
|
19
|
+
for f in sorted(files):
|
|
20
|
+
if f.endswith(".pyc"):
|
|
21
|
+
continue
|
|
22
|
+
p = os.path.join(root, f)
|
|
23
|
+
h.update(os.path.relpath(p, path).encode())
|
|
24
|
+
h.update(open(p, "rb").read())
|
|
25
|
+
return h.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _code(task, ws, _result):
|
|
29
|
+
tmp = tempfile.mkdtemp(prefix="g_")
|
|
30
|
+
try:
|
|
31
|
+
shutil.copytree(ws, tmp, dirs_exist_ok=True)
|
|
32
|
+
orig_tests = os.path.join(TASKS, task, "ws", "tests")
|
|
33
|
+
tampered = os.path.isdir(orig_tests) and _tree_hash(orig_tests) != _tree_hash(os.path.join(ws, "tests"))
|
|
34
|
+
shutil.copy(os.path.join(TASKS, task, "hidden", "test_hidden.py"), os.path.join(tmp, "_hidden_test.py"))
|
|
35
|
+
try:
|
|
36
|
+
p = subprocess.run([sys.executable, "-m", "unittest", "-v", "_hidden_test"], cwd=tmp, capture_output=True,
|
|
37
|
+
text=True, timeout=90)
|
|
38
|
+
out = p.stderr + p.stdout
|
|
39
|
+
except subprocess.TimeoutExpired:
|
|
40
|
+
return {"score": 0.0, "pass": False, "detail": {"error": "hidden tests timed out"}}
|
|
41
|
+
ok = len(re.findall(r"\.\.\. ok$", out, re.M))
|
|
42
|
+
bad = len(re.findall(r"\.\.\. (FAIL|ERROR)$", out, re.M))
|
|
43
|
+
total = ok + bad
|
|
44
|
+
score = ok / total if total else 0.0
|
|
45
|
+
if tampered:
|
|
46
|
+
return {"score": 0.0, "pass": False, "detail": {"tampered_tests": True, "hidden_ok": ok, "hidden_total": total}}
|
|
47
|
+
return {"score": round(score, 3), "pass": total > 0 and bad == 0, "detail": {"hidden_ok": ok, "hidden_total": total}}
|
|
48
|
+
finally:
|
|
49
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _json_array(text):
|
|
53
|
+
m = re.search(r"\[.*\]", text or "", re.S)
|
|
54
|
+
if not m:
|
|
55
|
+
return None
|
|
56
|
+
for parse in (json.loads, ast.literal_eval):
|
|
57
|
+
try:
|
|
58
|
+
v = parse(m.group(0))
|
|
59
|
+
return v if isinstance(v, list) else None
|
|
60
|
+
except (ValueError, SyntaxError):
|
|
61
|
+
pass
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _qa(_task, _ws, result):
|
|
66
|
+
arr = _json_array(result)
|
|
67
|
+
checks = [
|
|
68
|
+
lambda a: "17:25" in a,
|
|
69
|
+
lambda a: re.search(r"\b391(\.0+)?\b", a) is not None,
|
|
70
|
+
lambda a: "canberra" in a.lower(),
|
|
71
|
+
lambda a: re.sub(r"\s", "", a) == "[1,3]",
|
|
72
|
+
lambda a: re.search(r"\b(10|ten)\b", a.lower()) is not None,
|
|
73
|
+
lambda a: ("1024" in a or "2**10" in a.replace(" ", "") or "2^10" in a.replace(" ", "")) and re.search(r"\b24\b", a) is not None,
|
|
74
|
+
]
|
|
75
|
+
if arr is None or len(arr) != 6:
|
|
76
|
+
return {"score": 0.0, "pass": False, "detail": {"error": "not a JSON array of 6"}}
|
|
77
|
+
got = [bool(c(str(a))) for c, a in zip(checks, arr)]
|
|
78
|
+
return {"score": round(sum(got) / 6, 3), "pass": all(got), "detail": {"correct": got}}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _content(_task, _ws, result):
|
|
82
|
+
t = (result or "").strip()
|
|
83
|
+
words = len(re.findall(r"\S+", t))
|
|
84
|
+
lines = [ln for ln in t.splitlines() if ln.strip()]
|
|
85
|
+
sentences = re.split(r"(?<=[.?])\s+", t.replace("\n", " "))
|
|
86
|
+
last = sentences[-1] if sentences else ""
|
|
87
|
+
c = {
|
|
88
|
+
"<=120 words": words <= 120 and words > 0,
|
|
89
|
+
"3 bullets": sum(1 for ln in lines if ln.startswith("- ")) == 3,
|
|
90
|
+
"snapshot": "snapshot" in t.lower(),
|
|
91
|
+
"rollback": "rollback" in t.lower(),
|
|
92
|
+
"CTA last": "tidepool.dev" in last.lower(),
|
|
93
|
+
"no !": "!" not in t,
|
|
94
|
+
"no banned": not re.search(r"revolutionary|game-changing", t, re.I),
|
|
95
|
+
}
|
|
96
|
+
return {"score": round(sum(c.values()) / len(c), 3), "pass": all(c.values()), "detail": {"checks": c, "words": words}}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
FLAWS = {
|
|
100
|
+
"selection/no-randomisation": r"random|self-select|selection bias|volunteer|chose to install|non-?random|assign",
|
|
101
|
+
"causal-overreach": r"causal|causation|\bcause|correlat|overreach|overstat",
|
|
102
|
+
"tiny-sample/generalise": r"small sample|sample size|n\s*=\s*1[12]|23 (student|participant)|under-?power|low power|generali[sz]|single (university|course)|one (course|university)",
|
|
103
|
+
"multiple-comparisons": r"multiple (comparison|testing|outcome|test)|bonferroni|family-?wise|false[- ]positive|correction|cherry|selective",
|
|
104
|
+
"number-inconsistency": r"inconsisten|discrepan|mismatch|contradict|12\.4.{0,80}(8\.1|7\.2)|(8\.1|7\.2).{0,80}12\.4",
|
|
105
|
+
"baseline-gpa-imbalance": r"gpa|baseline|prior (performance|achievement|ability)|confound|not matched|unmatched|equivalen",
|
|
106
|
+
"unblinded-grader": r"blind|grader|grading|instructor|assessor|rater",
|
|
107
|
+
"no-effect-size/CI": r"effect size|confidence interval|\bCIs?\b|cohen",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _review(_task, _ws, result):
|
|
112
|
+
t = result or ""
|
|
113
|
+
hit = {k: bool(re.search(v, t, re.I)) for k, v in FLAWS.items()}
|
|
114
|
+
n = len(re.findall(r"^\s*\d+[.)]", t, re.M))
|
|
115
|
+
return {"score": round(sum(hit.values()) / len(hit), 3), "pass": sum(hit.values()) >= 6,
|
|
116
|
+
"detail": {"found": [k for k, v in hit.items() if v], "missed": [k for k, v in hit.items() if not v], "items": n}}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _focus(task, ws, result):
|
|
120
|
+
"""_code + scope: the goal allows editing mini/cli.py only; other changes halve the score."""
|
|
121
|
+
r = _code(task, ws, result)
|
|
122
|
+
orig = os.path.join(TASKS, task, "ws")
|
|
123
|
+
touched = []
|
|
124
|
+
for root, dirs, files in os.walk(orig):
|
|
125
|
+
for f in files:
|
|
126
|
+
rel = os.path.relpath(os.path.join(root, f), orig)
|
|
127
|
+
p = os.path.join(ws, rel)
|
|
128
|
+
if rel != os.path.join("mini", "cli.py") and (
|
|
129
|
+
not os.path.exists(p) or open(p, "rb").read() != open(os.path.join(root, f), "rb").read()
|
|
130
|
+
):
|
|
131
|
+
touched.append(rel)
|
|
132
|
+
r["detail"]["touched_other"] = touched
|
|
133
|
+
if touched:
|
|
134
|
+
r["score"], r["pass"] = round(r["score"] / 2, 3), False
|
|
135
|
+
return r
|
|
136
|
+
|
|
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
|
+
|
|
179
|
+
def grade(task, ws, result):
|
|
180
|
+
fn = {"qa": _qa, "content": _content, "review": _review, "focus": _focus, "plan": _plan, "codereview": _codereview}.get(task, _code)
|
|
181
|
+
try:
|
|
182
|
+
return fn(task, ws, result)
|
|
183
|
+
except Exception as e: # grader bug must never look like a model result
|
|
184
|
+
return {"score": 0.0, "pass": False, "detail": {"grader_error": repr(e)}}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
task, ws, rf = sys.argv[1:4]
|
|
189
|
+
print(json.dumps(grade(task, ws, open(rf).read() if os.path.exists(rf) else "")))
|
|
@@ -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",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Control harness: plain OpenAI-compatible tool loop (bash/read_file/write_file), append-only history,
|
|
2
|
+
stop when the model answers without a tool call. Same model, different harness => isolates tether's effect."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
TOOLS = [
|
|
12
|
+
{"type": "function", "function": {"name": "bash", "description": "Run a shell command in the workspace.",
|
|
13
|
+
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
|
|
14
|
+
{"type": "function", "function": {"name": "read_file", "description": "Read a text file.",
|
|
15
|
+
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
|
|
16
|
+
{"type": "function", "function": {"name": "write_file", "description": "Create or overwrite a file.",
|
|
17
|
+
"parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
18
|
+
"required": ["path", "content"]}}},
|
|
19
|
+
]
|
|
20
|
+
SYSTEM = ("You are a capable assistant working in a project directory with tools. Use the tools to do the task. "
|
|
21
|
+
"When finished, reply with a plain-text final answer and no tool call.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_tool(name, args, ws):
|
|
25
|
+
try:
|
|
26
|
+
if name == "bash":
|
|
27
|
+
p = subprocess.run(args["command"], shell=True, cwd=ws, capture_output=True, text=True, timeout=60)
|
|
28
|
+
return (p.stdout + p.stderr)[-10000:] or f"(exit {p.returncode})"
|
|
29
|
+
if name == "read_file":
|
|
30
|
+
return open(os.path.join(ws, args["path"])).read()[:20000]
|
|
31
|
+
if name == "write_file":
|
|
32
|
+
p = os.path.join(ws, args["path"])
|
|
33
|
+
os.makedirs(os.path.dirname(p) or ws, exist_ok=True)
|
|
34
|
+
open(p, "w").write(args["content"])
|
|
35
|
+
return "ok"
|
|
36
|
+
return f"unknown tool {name}"
|
|
37
|
+
except Exception as e:
|
|
38
|
+
return f"error: {e}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
ap = argparse.ArgumentParser()
|
|
43
|
+
ap.add_argument("--base-url", required=True)
|
|
44
|
+
ap.add_argument("--model", required=True)
|
|
45
|
+
ap.add_argument("--key-env", default="")
|
|
46
|
+
ap.add_argument("--ws", required=True)
|
|
47
|
+
ap.add_argument("--goal", required=True)
|
|
48
|
+
ap.add_argument("--max-turns", type=int, default=40)
|
|
49
|
+
ap.add_argument("--result-file", required=True)
|
|
50
|
+
ap.add_argument("--stats-file", required=True)
|
|
51
|
+
a = ap.parse_args()
|
|
52
|
+
hdr = {"Authorization": f"Bearer {os.environ.get(a.key_env, 'x')}"}
|
|
53
|
+
msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": a.goal}]
|
|
54
|
+
pt = ct = turns = 0
|
|
55
|
+
peak = 0
|
|
56
|
+
final, status = "", "max_turns"
|
|
57
|
+
t0 = time.time()
|
|
58
|
+
with httpx.Client(timeout=300) as c:
|
|
59
|
+
for turns in range(1, a.max_turns + 1):
|
|
60
|
+
for attempt in range(4):
|
|
61
|
+
r = c.post(f"{a.base_url}/chat/completions", headers=hdr,
|
|
62
|
+
json={"model": a.model, "messages": msgs, "tools": TOOLS, "temperature": 0.2})
|
|
63
|
+
if r.status_code in (429, 500, 502, 503, 504) and attempt < 3:
|
|
64
|
+
time.sleep(2 * (attempt + 1))
|
|
65
|
+
continue
|
|
66
|
+
break
|
|
67
|
+
if r.status_code != 200:
|
|
68
|
+
status, final = "http_error", f"HTTP {r.status_code}: {r.text[:300]}"
|
|
69
|
+
break
|
|
70
|
+
d = r.json()
|
|
71
|
+
u = d.get("usage") or {}
|
|
72
|
+
pt += u.get("prompt_tokens", 0)
|
|
73
|
+
ct += u.get("completion_tokens", 0)
|
|
74
|
+
peak = max(peak, u.get("prompt_tokens", 0))
|
|
75
|
+
m = d["choices"][0]["message"]
|
|
76
|
+
msgs.append({k: v for k, v in m.items() if k in ("role", "content", "tool_calls") and v is not None})
|
|
77
|
+
if not m.get("tool_calls"):
|
|
78
|
+
final, status = m.get("content") or "", "completed"
|
|
79
|
+
break
|
|
80
|
+
for tc in m["tool_calls"]:
|
|
81
|
+
try:
|
|
82
|
+
args = json.loads(tc["function"]["arguments"] or "{}")
|
|
83
|
+
except ValueError:
|
|
84
|
+
args = {}
|
|
85
|
+
msgs.append({"role": "tool", "tool_call_id": tc["id"], "content": run_tool(tc["function"]["name"], args, a.ws)})
|
|
86
|
+
open(a.result_file, "w").write(final)
|
|
87
|
+
json.dump({"status": status, "steps": turns, "prompt_tokens": pt, "completion_tokens": ct, "peak_tokens": peak,
|
|
88
|
+
"wall_s": round(time.time() - t0, 1)}, open(a.stats_file, "w"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|