tether-sdk 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/AGENTS.md +2 -1
  2. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/PKG-INFO +1 -1
  3. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/agent-interface.md +1 -1
  4. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/providers.md +22 -2
  5. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/release-notes.md +14 -0
  6. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/troubleshooting.md +1 -0
  7. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/cli-and-headless.md +12 -0
  8. tether_sdk-0.2.2/tests/fixtures/dialects.jsonl +6 -0
  9. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/providers/test_declarative.py +98 -0
  10. tether_sdk-0.2.2/tests/providers/test_dialects.py +79 -0
  11. tether_sdk-0.2.2/tests/test_context_config.py +101 -0
  12. tether_sdk-0.2.2/tests/test_misses.py +108 -0
  13. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_tether.py +1 -1
  14. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/__init__.py +3 -1
  15. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/cli.py +55 -0
  16. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/harness.py +14 -4
  17. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/mcp.py +4 -0
  18. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/providers/__init__.py +2 -0
  19. tether_sdk-0.2.2/tether/providers/base.py +183 -0
  20. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/providers/declarative.py +74 -13
  21. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/store.py +26 -0
  22. tether_sdk-0.2.1/tether/providers/base.py +0 -140
  23. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/.github/workflows/ci.yml +0 -0
  24. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/.github/workflows/delivery.yml +0 -0
  25. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/.github/workflows/docs.yml +0 -0
  26. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/.gitignore +0 -0
  27. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/LICENSE +0 -0
  28. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/README.md +0 -0
  29. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/architecture.md +0 -0
  30. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/benchmarks.md +0 -0
  31. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/extending.md +0 -0
  32. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/index.md +0 -0
  33. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/javascripts/mathjax.js +0 -0
  34. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/context-and-store.md +0 -0
  35. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/harness.md +0 -0
  36. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/index.md +0 -0
  37. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/multiplexer-and-decorator.md +0 -0
  38. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/sandbox-and-skills.md +0 -0
  39. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/reference/tools.md +0 -0
  40. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/releasing.md +0 -0
  41. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/agent-interface.md +0 -0
  42. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/built-in-tools.md +0 -0
  43. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/index.md +0 -0
  44. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/multi-agent.md +0 -0
  45. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/sdk-and-custom-tools.md +0 -0
  46. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/security-and-sandbox.md +0 -0
  47. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/skills.md +0 -0
  48. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/docs/tutorial/workflows-and-telemetry.md +0 -0
  49. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/README.md +0 -0
  50. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/checks.py +0 -0
  51. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/course.yaml +0 -0
  52. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/ruff.toml +0 -0
  53. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/skills/planner/SKILL.md +0 -0
  54. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/skills/searcher/SKILL.md +0 -0
  55. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/skills/searcher/tools.py +0 -0
  56. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/skills/writer/SKILL.md +0 -0
  57. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/examples/course/skills/writer/tools.py +0 -0
  58. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/mkdocs.yml +0 -0
  59. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/pyproject.toml +0 -0
  60. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/skills/researcher/SKILL.md +0 -0
  61. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/__init__.py +0 -0
  62. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/conftest.py +0 -0
  63. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/providers/__init__.py +0 -0
  64. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/providers/test_anthropic.py +0 -0
  65. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/providers/test_base.py +0 -0
  66. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/providers/test_ollama.py +0 -0
  67. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/providers/test_openai.py +0 -0
  68. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_agent_interface.py +0 -0
  69. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_cli.py +0 -0
  70. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_context.py +0 -0
  71. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_decorator.py +0 -0
  72. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_harness.py +0 -0
  73. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_integration.py +0 -0
  74. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_multiplexer.py +0 -0
  75. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_pipeline.py +0 -0
  76. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_release_hardening.py +0 -0
  77. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_retro.py +0 -0
  78. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_sandbox.py +0 -0
  79. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_security_hardening.py +0 -0
  80. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_skills.py +0 -0
  81. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_store.py +0 -0
  82. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/test_structured_history.py +0 -0
  83. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/__init__.py +0 -0
  84. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/test_agent.py +0 -0
  85. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/test_bash.py +0 -0
  86. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/test_done.py +0 -0
  87. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/test_edit.py +0 -0
  88. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/test_mem.py +0 -0
  89. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tests/tools/test_web.py +0 -0
  90. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/api.py +0 -0
  91. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/context.py +0 -0
  92. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/decorator.py +0 -0
  93. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/multiplexer.py +0 -0
  94. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/pipeline.py +0 -0
  95. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/providers/anthropic.py +0 -0
  96. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/providers/ollama.py +0 -0
  97. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/providers/openai.py +0 -0
  98. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/retro.py +0 -0
  99. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/sandbox.py +0 -0
  100. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/schema.py +0 -0
  101. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/skills.py +0 -0
  102. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/__init__.py +0 -0
  103. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/agent.py +0 -0
  104. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/bash.py +0 -0
  105. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/done.py +0 -0
  106. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/edit.py +0 -0
  107. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/mem.py +0 -0
  108. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/tether/tools/web.py +0 -0
  109. {tether_sdk-0.2.1 → tether_sdk-0.2.2}/uv.lock +0 -0
@@ -70,7 +70,7 @@ Tether implements the architectural principles from *"LLM-Native Applications as
70
70
  ```text
71
71
  tether/
72
72
  ├── __init__.py # Public SDK exports: Harness, Pipeline, Session, Skill, SandboxPolicy, tool
73
- ├── cli.py # Headless-capable CLI: `tether run|pipeline|status|read|query|send|list|wait|stats|retro|events|mcp` (`--json` + documented exit codes)
73
+ ├── cli.py # Headless-capable CLI: `tether run|pipeline|status|read|query|send|list|wait|stats|retro|events|misses|mcp` (`--json` + documented exit codes)
74
74
  ├── harness.py # Control Plane: bounded agent loop, pipelines, SQLite inbox, worker spawn
75
75
  ├── pipeline.py # Workflow Plane: declarative multi-stage YAML pipelines & model routing
76
76
  ├── api.py # Agent-facing JSON session view, exit codes, events (`--json`)
@@ -155,6 +155,7 @@ tether/
155
155
  - `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` (default `https://api.anthropic.com`)
156
156
 
157
157
  ---
158
+ - **Context config (#54):** `max_context_tokens` / `fold_observations_after` resolve as explicit argument (`--context-tokens` / `--fold-after`, MCP `context_tokens` / `fold_after`) > `providers.yaml` entry keys `context_tokens` / `fold_after` > defaults 8192 / 2.
158
159
 
159
160
  ## 4. Development Workflow & Commands
160
161
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tether-sdk
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -42,7 +42,7 @@ Optional: `pip install "tether-sdk[mcp]"` (installs the official `mcp` SDK; core
42
42
 
43
43
  | Tool | Arguments |
44
44
  |------|-----------|
45
- | `tether_run` | `goal`, `model`, `skill`, `tools`, `verify[]`, `max_steps`, `sandbox`, `no_network`, `output_schema`, `wait` (default true) |
45
+ | `tether_run` | `goal`, `model`, `skill`, `tools`, `verify[]`, `max_steps`, `context_tokens`, `fold_after`, `sandbox`, `no_network`, `output_schema`, `wait` (default true) |
46
46
  | `tether_status` | `id` |
47
47
  | `tether_wait` | `ids[]`, `timeout` |
48
48
  | `tether_read` | `id`, `mode` = `full` \| `digest` \| `steps` |
@@ -34,12 +34,18 @@ claude-cli:
34
34
  reply: result
35
35
  usage: {prompt: [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens], completion: usage.output_tokens}
36
36
  timeout: 180
37
+ context_tokens: 32000
38
+ fold_after: 8
37
39
  ```
38
40
 
39
41
  ```bash
40
42
  tether run --model claude-cli/sonnet "fix the failing test"
41
43
  ```
42
44
 
45
+ ### Context budget
46
+
47
+ Every entry, of any kind, may set two optional positive integers: `context_tokens` (the context budget, default 8192) and `fold_after` (how many recent turns keep full observations, default 2). The `--context-tokens` / `--fold-after` flags and the Harness arguments override them.
48
+
43
49
  ### Kinds
44
50
 
45
51
  | `kind` | Fields |
@@ -55,11 +61,25 @@ tether run --model claude-cli/sonnet "fix the failing test"
55
61
 
56
62
  A `command` entry wraps any program that takes a prompt and prints JSON.
57
63
 
58
- - **Placeholders**: `{model}`, `{system}` and `{conversation}` are substituted in `argv` items and in the `stdin` template. If `stdin` is omitted it defaults to `{conversation}`.
64
+ - **Placeholders**: `{model}`, `{system}`, `{conversation}` and `{schema}` (the act JSON schema, compact) are substituted in `argv` items and in the `stdin` template. If `stdin` is omitted it defaults to `{conversation}`.
59
65
  - **No shell**: `argv` is executed directly, never through a shell.
60
66
  - **Reply**: `reply` is a dotted path into the JSON the command prints (`result` in the example).
61
67
  - **Usage**: `usage.prompt` is a path or a list of paths (their values are summed); `usage.completion` is a path. Both are optional.
62
- - **Protocol**: the model must answer with a single JSON `{"cmd", "stdin"}` object. The `{system}` placeholder already contains that instruction.
68
+ - **Constrained output**: use `{schema}` to make the CLI constrain its reply to the act schema, for example `argv: [claude, -p, --json-schema, "{schema}", --output-format, json]`. The reply may then be an object with `cmd` and optional `stdin` instead of a string; if the `reply` path gives no string or object, a top-level `structured_output` object is used.
69
+
70
+ Measured cost (v0.2.2, one small task): with `claude -p`, `--json-schema` adds about 2k prompt tokens to every call (the CLI wraps the schema in a hidden tool), so the same 5-step task used ~26k prompt tokens instead of ~7k. Use it when a model's plain replies are not being understood, not as a default.
71
+ - **Protocol**: the model should answer with a single JSON `{"cmd", "stdin"}` object. The `{system}` placeholder already contains that instruction.
72
+ - **Dialects**: when the reply is not that JSON, Tether tries table-driven extractors in order, first match wins: `<tool_call>` JSON, raw JSON containing `"cmd"`, `<parameter name="cmd">X</parameter>` with optional `<parameter name="stdin">Y</parameter>` (inside any wrapper such as `<invoke>`), and text `act(...)`.
73
+ - **`dialects:`** (optional): a list of mappings, each with a `regex` (compiled with `re.DOTALL`) that defines the named group `cmd` and optionally `stdin`. Custom dialects are tried after the built-in ones. Python users can also call `tether.register_dialect(fn)` with a `(str) -> ActionCall | None` function.
74
+
75
+ ```yaml
76
+ mycli:
77
+ kind: command
78
+ argv: [mycli, "{conversation}"]
79
+ reply: result
80
+ dialects:
81
+ - regex: 'RUN\[(?P<cmd>.*?)\]WITH\[(?P<stdin>.*?)\]'
82
+ ```
63
83
 
64
84
  ### Rules
65
85
 
@@ -1,5 +1,19 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.2.2 (2026-10-19)
4
+
5
+ Dogfooding fixes: Tether implemented most of this release itself through the CLI and a `claude -p` provider.
6
+
7
+ ### Features
8
+
9
+ - `{schema}` placeholder for `command` providers (#51): constrain output to the act schema (e.g. `claude -p --json-schema '{schema}'`); replies may be structured objects with `cmd`/`stdin`.
10
+ - `tether misses [session_id] [--model M] [--limit N] [--json]` (#53): list unparsed model replies (nudges) as fixture-ready JSONL; paste lines into `tests/fixtures/dialects.jsonl` after filling `expected`.
11
+
12
+ ### Fixes
13
+
14
+ - The context budget and observation fold window are now configurable (#54): `--context-tokens` / `--fold-after` on `tether run` and `tether pipeline`, `context_tokens` / `fold_after` on `tether_run`, and per-provider keys in `providers.yaml`.
15
+ - Tool-call dialect extraction is now table-driven (#52): built-in extractors for `<tool_call>` JSON, raw JSON, `<parameter name="cmd">` blocks and text `act(...)`, extendable with `tether.register_dialect` or a `dialects:` key on `command` entries in `providers.yaml`.
16
+
3
17
  ## v0.2.1 (2026-10-19)
4
18
 
5
19
  Add model backends without code.
@@ -10,6 +10,7 @@ Symptom, cause, fix. Most entries come from building the [course-builder example
10
10
  | Files contain a literal `\n` | Model sent escaped newlines in stdin | Fixed in v0.1.2 for `edit write`, `edit replace` and `done` |
11
11
  | Tool with multi-word argument fails (`unexpected keyword argument`, wrong value) | Unquoted words were not joined | Fixed in v0.1.2. See [argument parsing](tutorial/sdk-and-custom-tools.md#argument-parsing-v012) |
12
12
  | `Error: unknown command` for a tool defined in `tools.py` | The tool is not in the skill `tools:` list ([#24](https://github.com/igormcsouza/tether/issues/24)) | Add its name to `tools:`. See [Skills](tutorial/skills.md#tools-py-and-the-tools-list) |
13
+ | Model answers are not understood (many nudges in `tether stats`) | The reply format is not parsed into an action | Run `tether misses`, then add a dialect: paste the lines into `tests/fixtures/dialects.jsonl` or add a `dialects:` regex to `providers.yaml` |
13
14
  | Gate with inline `awk`/`sed` is blocked as "absolute path outside workspace" | Fixed in v0.1.4 ([#31](https://github.com/igormcsouza/tether/issues/31)); older versions saw a program starting with `/` as a path | Upgrade, or move the logic into a script (`python3 checks.py ...`). See [Security and Sandbox](tutorial/security-and-sandbox.md#example-awksed-programs-that-start-with) |
14
15
  | `Ollama model … not found; installed: …` | The model name is not pulled; Tether no longer substitutes another one ([#21](https://github.com/igormcsouza/tether/issues/21)) | `ollama pull <model>`, fix the name, or set `TETHER_OLLAMA_FALLBACK=1` to opt in to a substitute |
15
16
  | `tether mcp` prints "MCP support is not installed" | The optional extra is missing | `pip install "tether-sdk[mcp]"`. See [Use Tether Inside Your Harness](tutorial/agent-interface.md) |
@@ -76,10 +76,22 @@ tether query "$SESSION_ID" "write-ahead log checkpointing" --top 3
76
76
  tether list
77
77
  ```
78
78
 
79
+ ## Context budget and fold window
80
+
81
+ By default Tether keeps an 8192-token context budget and folds observations older than 2 turns. Tune both per run with `--context-tokens N` and `--fold-after N` (on `tether run` and `tether pipeline`), or per model in `providers.yaml` with `context_tokens` / `fold_after`. Flags override the provider entry, which overrides the defaults.
82
+
83
+ ```bash
84
+ tether run --context-tokens 32000 --fold-after 8 "refactor the parser"
85
+ ```
86
+
79
87
  ## Scripting: `--json` and exit codes
80
88
 
81
89
  `run`, `pipeline`, `status`, `list`, `read`, `wait`, `send` and `stats` accept `--json`. Exit codes: `0` completed, `1` usage error, `2` waiting for input, `3` gate failed, `4` max steps, `5` worker error. A failure reason goes to stderr, never stdout. `--result-file <path>` writes the deliverable to a file, `--output-schema <schema.json>` rejects a `done` whose JSON does not match, and `tether events <id> --follow` streams turns as JSONL. Details: [Agent Interface reference](../reference/agent-interface.md).
82
90
 
91
+ ### When a model's replies are not understood
92
+
93
+ If a model keeps answering in a format Tether cannot parse into an action (many nudges in `tether stats`), run `tether misses [session_id] [--model M] [--limit N] [--json]`. It lists the unparsed replies; with `--json` each line is fixture-ready. Paste lines into `tests/fixtures/dialects.jsonl` after filling `expected`, or add a `dialects:` regex to `providers.yaml`.
94
+
83
95
  ## Live Inbox Injection & Auto-Resume (`tether send`)
84
96
 
85
97
  Every session has a persistent SQLite `inbox` queue:
@@ -0,0 +1,6 @@
1
+ {"reply": "<tool_call>{\"name\": \"act\", \"arguments\": {\"cmd\": \"ls -la\"}}</tool_call>", "cmd": "ls -la", "stdin": null}
2
+ {"reply": "Sure: {\"cmd\": \"echo hi\"}", "cmd": "echo hi", "stdin": null}
3
+ {"reply": "act(\"done\", stdin=\"x\")", "cmd": "done", "stdin": "x"}
4
+ {"reply": "<invoke name=\"act\">\n<parameter name=\"cmd\"> pytest -q </parameter>\n</invoke>", "cmd": "pytest -q", "stdin": null}
5
+ {"reply": "<invoke name=\"act\">\n<parameter name=\"cmd\">edit write a.txt</parameter>\n<parameter name=\"stdin\">line1\nline2\n</parameter>\n</invoke>", "cmd": "edit write a.txt", "stdin": "line1\nline2\n"}
6
+ {"reply": "I will think about this and answer in plain prose.", "cmd": null, "stdin": null}
@@ -15,6 +15,7 @@ from tether.providers import (
15
15
  )
16
16
  from tether.providers.base import ModelResponse
17
17
  from tether.providers.declarative import (
18
+ ACT_SCHEMA,
18
19
  CommandProvider,
19
20
  build_declared,
20
21
  load_declared,
@@ -300,3 +301,100 @@ def test_command_validation_errors(
300
301
  _write(monkeypatch, tmp_path, f"cli:\n kind: command\n {body}")
301
302
  with pytest.raises(ValueError, match=f"providers.yaml: entry `cli`.*{message}"):
302
303
  load_declared()
304
+
305
+
306
+ def test_command_custom_dialect_end_to_end() -> None:
307
+ from tether.providers.declarative import _build_dialects
308
+
309
+ text = "RUN[ls -la]WITH[body]"
310
+ code = f"import json; print(json.dumps({{'r': {text!r}}}))"
311
+ dialects = _build_dialects([{"regex": r"RUN\[(?P<cmd>.*?)\]WITH\[(?P<stdin>.*?)\]"}])
312
+ resp = _run(CommandProvider("m", argv=_script(code), reply="r", dialects=dialects))
313
+ assert resp.action is not None
314
+ assert resp.action.cmd == "ls -la"
315
+ assert resp.action.stdin == "body"
316
+
317
+
318
+ def test_command_custom_dialect_no_match_and_empty_cmd() -> None:
319
+ from tether.providers.declarative import _build_dialects
320
+
321
+ dialects = _build_dialects([{"regex": r"RUN\[(?P<cmd>.*?)\]"}])
322
+ assert dialects[0]("nothing here") is None
323
+ assert dialects[0]("RUN[ ]") is None
324
+ call = dialects[0]("RUN[ pwd ]")
325
+ assert call is not None
326
+ assert call.cmd == "pwd"
327
+ assert call.stdin is None
328
+
329
+
330
+ @pytest.mark.parametrize(
331
+ ("dialects", "message"),
332
+ [
333
+ ("dialects: nope", "invalid `dialects`"),
334
+ ("dialects: [1]", "`regex` required"),
335
+ ("dialects: [{foo: bar}]", "`regex` required"),
336
+ ("dialects: [{regex: '('}]", "invalid `dialects` regex"),
337
+ ("dialects: [{regex: 'abc'}]", "named group `cmd`"),
338
+ ],
339
+ )
340
+ def test_command_dialects_validation_errors(
341
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, dialects: str, message: str
342
+ ) -> None:
343
+ _write(
344
+ monkeypatch,
345
+ tmp_path,
346
+ f"cli:\n kind: command\n argv: [echo]\n reply: r\n {dialects}\n",
347
+ )
348
+ with pytest.raises(ValueError, match=f"providers.yaml: entry `cli`.*{message}"):
349
+ load_declared()
350
+
351
+
352
+ _SCHEMA = json.dumps(ACT_SCHEMA, separators=(",", ":"))
353
+
354
+
355
+ def test_schema_placeholder_in_argv_and_stdin() -> None:
356
+ code = (
357
+ "import sys,json;"
358
+ "s = sys.argv[1] + '|' + sys.stdin.read();"
359
+ "print(json.dumps({'r': json.dumps({'cmd': 'a', 'stdin': s})}))"
360
+ )
361
+ provider = CommandProvider("m", argv=_script(code, "{schema}"), reply="r", stdin="in:{schema}")
362
+ resp = _run(provider)
363
+ assert resp.action is not None
364
+ assert resp.action.stdin == f"{_SCHEMA}|in:{_SCHEMA}"
365
+
366
+
367
+ def test_reply_dict_with_cmd_and_stdin() -> None:
368
+ code = "import json;print(json.dumps({'r': {'cmd': 'ls', 'stdin': 'x'}}))"
369
+ resp = _run(CommandProvider("m", argv=_script(code), reply="r"))
370
+ assert resp.action is not None
371
+ assert (resp.action.cmd, resp.action.stdin) == ("ls", "x")
372
+ assert json.loads(resp.content) == {"cmd": "ls", "stdin": "x"}
373
+
374
+
375
+ def test_reply_dict_with_cmd_no_stdin() -> None:
376
+ code = "import json;print(json.dumps({'r': {'cmd': 'ls', 'stdin': None}}))"
377
+ resp = _run(CommandProvider("m", argv=_script(code), reply="r"))
378
+ assert resp.action is not None
379
+ assert resp.action.stdin is None
380
+
381
+
382
+ def test_structured_output_fallback() -> None:
383
+ code = "import json;print(json.dumps({'result': '', 'structured_output': {'cmd': 'pwd'}}))"
384
+ resp = _run(CommandProvider("m", argv=_script(code), reply="missing"))
385
+ assert resp.action is not None
386
+ assert resp.action.cmd == "pwd"
387
+
388
+
389
+ def test_reply_dict_without_cmd_falls_back() -> None:
390
+ code = "import json;print(json.dumps({'r': {'foo': 1}}))"
391
+ resp = _run(CommandProvider("m", argv=_script(code), reply="r"))
392
+ assert resp.content == ""
393
+ assert resp.action is None
394
+
395
+
396
+ def test_reply_string_still_works() -> None:
397
+ code = "import json;print(json.dumps({'r': json.dumps({'cmd': 'ls'})}))"
398
+ resp = _run(CommandProvider("m", argv=_script(code), reply="r"))
399
+ assert resp.action is not None
400
+ assert resp.action.cmd == "ls"
@@ -0,0 +1,79 @@
1
+ """Table-driven tests for tool-call dialect extractors."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from tether.providers import base
9
+ from tether.providers.base import ActionCall, extract_fallback_action, register_dialect
10
+
11
+ FIXTURE = Path(__file__).parent.parent / "fixtures" / "dialects.jsonl"
12
+ ROWS = [json.loads(line) for line in FIXTURE.read_text().splitlines() if line.strip()]
13
+
14
+
15
+ @pytest.mark.parametrize("row", ROWS, ids=[str(i) for i in range(len(ROWS))])
16
+ def test_dialect_fixture_rows(row: dict) -> None:
17
+ call = extract_fallback_action(row["reply"])
18
+ if row["cmd"] is None:
19
+ assert call is None
20
+ else:
21
+ assert call is not None
22
+ assert call.cmd == row["cmd"]
23
+ assert call.stdin == row["stdin"]
24
+
25
+
26
+ def test_fixture_covers_required_forms() -> None:
27
+ assert len(ROWS) >= 6
28
+ assert any(r["cmd"] is None for r in ROWS)
29
+
30
+
31
+ def test_empty_text_returns_none() -> None:
32
+ assert extract_fallback_action("") is None
33
+
34
+
35
+ def test_parameter_tags_empty_cmd_is_none() -> None:
36
+ text = '<parameter name="cmd"> </parameter>'
37
+ assert extract_fallback_action(text) is None
38
+
39
+
40
+ def test_register_dialect_appends_and_is_used() -> None:
41
+ def custom(text: str) -> ActionCall | None:
42
+ if text.startswith("RUN:"):
43
+ return ActionCall(cmd=text[4:].strip(), stdin=None)
44
+ return None
45
+
46
+ before = list(base.DIALECTS)
47
+ try:
48
+ assert register_dialect(custom) is custom
49
+ assert base.DIALECTS[-1] is custom
50
+ call = extract_fallback_action("RUN: ls")
51
+ assert call is not None
52
+ assert call.cmd == "ls"
53
+ finally:
54
+ base.DIALECTS.remove(custom)
55
+ assert base.DIALECTS == before
56
+ assert extract_fallback_action("RUN: ls") is None
57
+
58
+
59
+ def test_extra_extractors_tried_after_builtins() -> None:
60
+ def custom(text: str) -> ActionCall | None:
61
+ return ActionCall(cmd="extra", stdin=None) if "zzz" in text else None
62
+
63
+ call = extract_fallback_action("zzz", extra=[custom])
64
+ assert call is not None
65
+ assert call.cmd == "extra"
66
+ assert extract_fallback_action("zzz") is None
67
+ builtin = extract_fallback_action('{"cmd": "ls"} zzz', extra=[custom])
68
+ assert builtin is not None
69
+ assert builtin.cmd == "ls"
70
+
71
+
72
+ def test_tool_call_block_with_non_object_payload_is_ignored() -> None:
73
+ assert extract_fallback_action("<tool_call>[1, 2]</tool_call>") is None
74
+
75
+
76
+ def test_act_ast_rejects_non_call_expression() -> None:
77
+ from tether.providers.base import _act_from_ast
78
+
79
+ assert _act_from_ast(") or (1") is None
@@ -0,0 +1,101 @@
1
+ """Configurable context budget and fold window (#54)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ from tether import ActionCall, Harness, ModelResponse, register_provider
12
+ from tether.cli import _async_main
13
+ from tether.providers.declarative import load_declared
14
+
15
+
16
+ def _yaml(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, text: str) -> None:
17
+ p = tmp_path / "p.yaml"
18
+ p.write_text(text, encoding="utf-8")
19
+ monkeypatch.setenv("TETHER_PROVIDERS", str(p))
20
+
21
+
22
+ ENTRY = "decl:\n kind: openai\n base_url: http://x\n api_key_env: K\n"
23
+
24
+
25
+ def test_precedence(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
26
+ db = tmp_path / "t.db"
27
+ monkeypatch.setenv("TETHER_PROVIDERS", str(tmp_path / "none.yaml"))
28
+ h = Harness(model="decl/m", db_path=db)
29
+ assert (h.max_context_tokens, h.fold_observations_after) == (8192, 2)
30
+ _yaml(monkeypatch, tmp_path, ENTRY + " context_tokens: 32000\n fold_after: 8\n")
31
+ h = Harness(model="decl/m", db_path=db)
32
+ assert (h.max_context_tokens, h.fold_observations_after) == (32000, 8)
33
+ h = Harness(model="decl/m", db_path=db, max_context_tokens=100, fold_observations_after=5)
34
+ assert (h.max_context_tokens, h.fold_observations_after) == (100, 5)
35
+ _yaml(monkeypatch, tmp_path, "decl:\n kind: nope\n")
36
+ h = Harness(model="decl/m", db_path=db)
37
+ assert h.max_context_tokens == 8192
38
+
39
+
40
+ @pytest.mark.parametrize("key", ["context_tokens", "fold_after"])
41
+ @pytest.mark.parametrize("bad", ["0", "-1", "true", "'8'", "1.5"])
42
+ def test_validation(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key: str, bad: str) -> None:
43
+ _yaml(monkeypatch, tmp_path, ENTRY + f" {key}: {bad}\n")
44
+ with pytest.raises(ValueError, match=f"entry `decl` has invalid `{key}` "):
45
+ load_declared()
46
+
47
+
48
+ class _P:
49
+ model_name = "x"
50
+
51
+ async def generate(self, messages: Any, tools: Any) -> ModelResponse:
52
+ return ModelResponse(
53
+ content="",
54
+ action=ActionCall(cmd="done", stdin="ok"),
55
+ prompt_tokens=1,
56
+ completion_tokens=1,
57
+ )
58
+
59
+
60
+ async def test_cli_flags(
61
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
62
+ ) -> None:
63
+ seen: list[Harness] = []
64
+
65
+ class Rec(Harness):
66
+ def __init__(self, *a: Any, **kw: Any) -> None:
67
+ super().__init__(*a, **kw)
68
+ seen.append(self)
69
+
70
+ monkeypatch.setattr("tether.cli.Harness", Rec)
71
+ register_provider("ctxcfg", lambda name: _P())
72
+ db = str(tmp_path / "t.db")
73
+ flags = ["--context-tokens", "123", "--fold-after", "7", "--db", db, "--model", "ctxcfg/m"]
74
+ assert await _async_main(["run", "--json", *flags, "g"]) == 0
75
+ assert json.loads(capsys.readouterr().out)["status"] == "COMPLETED"
76
+ await _async_main(["pipeline", *flags, str(tmp_path / "missing.yaml"), "g"])
77
+ assert len(seen) == 2
78
+ assert all((h.max_context_tokens, h.fold_observations_after) == (123, 7) for h in seen)
79
+
80
+
81
+ async def test_mcp_args(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
82
+ import mcp
83
+
84
+ from tether.mcp import build_server
85
+
86
+ seen: list[Harness] = []
87
+
88
+ class Rec(Harness):
89
+ def __init__(self, *a: Any, **kw: Any) -> None:
90
+ super().__init__(*a, **kw)
91
+ seen.append(self)
92
+
93
+ monkeypatch.setattr("tether.mcp.Harness", Rec)
94
+ register_provider("ctxmcp", lambda name: _P())
95
+ async with mcp.Client(build_server(tmp_path / "t.db", tmp_path)) as c:
96
+ res = await c.call_tool(
97
+ "tether_run",
98
+ {"goal": "g", "model": "ctxmcp/m", "context_tokens": 50, "fold_after": 4},
99
+ )
100
+ assert not res.is_error
101
+ assert (seen[0].max_context_tokens, seen[0].fold_observations_after) == (50, 4)
@@ -0,0 +1,108 @@
1
+ """`tether misses`: unparsed model replies as fixture-ready JSONL (#53)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ from tether import ActionCall, ModelResponse, register_provider
12
+ from tether.cli import _async_main
13
+ from tether.store import SQLiteStore
14
+
15
+
16
+ class Prose:
17
+ model_name = "x"
18
+
19
+ def __init__(self, text: str) -> None:
20
+ self.text = text
21
+ self.i = 0
22
+
23
+ async def generate(self, messages: Any, tools: Any) -> ModelResponse:
24
+ self.i += 1
25
+ return ModelResponse(
26
+ content=f"{self.text} #{self.i}", action=None, prompt_tokens=1, completion_tokens=1
27
+ )
28
+
29
+
30
+ class Done:
31
+ model_name = "x"
32
+
33
+ async def generate(self, messages: Any, tools: Any) -> ModelResponse:
34
+ return ModelResponse(
35
+ content="",
36
+ action=ActionCall(cmd="done", stdin="ok"),
37
+ prompt_tokens=1,
38
+ completion_tokens=1,
39
+ )
40
+
41
+
42
+ async def _run(capsys: pytest.CaptureFixture[str], argv: list[str]) -> tuple[int, str, str]:
43
+ code = await _async_main(argv)
44
+ cap = capsys.readouterr()
45
+ return code, cap.out, cap.err
46
+
47
+
48
+ async def _session(capsys: pytest.CaptureFixture[str], db: str, prefix: str, provider: Any) -> str:
49
+ register_provider(prefix, lambda name: provider)
50
+ argv = ["run", "--json", "--model", f"{prefix}/m", "--db", db, "--max-steps", "3", "g"]
51
+ _, out, _ = await _run(capsys, argv)
52
+ return str(json.loads(out)["id"])
53
+
54
+
55
+ async def test_misses_outputs_and_filters(
56
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
57
+ ) -> None:
58
+ db = str(tmp_path / "t.db")
59
+ s1 = await _session(capsys, db, "miss-a", Prose("a" * 500))
60
+ s2 = await _session(capsys, db, "miss-b", Prose("b"))
61
+
62
+ code, out, _ = await _run(capsys, ["misses", "--db", db])
63
+ assert code == 0
64
+ assert f"[{s1} step" in out and "miss-a/m]" in out
65
+ assert "a" * 300 in out and "a" * 301 not in out
66
+
67
+ code, out, _ = await _run(capsys, ["misses", "--json", "--db", db])
68
+ rows = [json.loads(line) for line in out.splitlines()]
69
+ assert code == 0 and len(rows) >= 2
70
+ assert {r["session"] for r in rows} == {s1, s2}
71
+ assert all(r["expected"] is None and r["reply"] and "step" in r for r in rows)
72
+ assert any(len(r["reply"]) > 300 for r in rows)
73
+
74
+ _, out, _ = await _run(capsys, ["misses", "--json", "--limit", "1", "--db", db])
75
+ assert len(out.splitlines()) == 1
76
+
77
+ _, out, _ = await _run(capsys, ["misses", "--json", "--model", "miss-b/m", "--db", db])
78
+ assert {json.loads(x)["session"] for x in out.splitlines()} == {s2}
79
+
80
+ _, out, _ = await _run(capsys, ["misses", s1, "--json", "--db", db])
81
+ assert {json.loads(x)["session"] for x in out.splitlines()} == {s1}
82
+
83
+ code, _, err = await _run(capsys, ["misses", "ses_nope", "--db", db])
84
+ assert code == 1 and "session `ses_nope` not found." in err
85
+
86
+
87
+ async def test_misses_empty(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
88
+ db = str(tmp_path / "t.db")
89
+ await _session(capsys, db, "miss-ok", Done())
90
+ code, out, _ = await _run(capsys, ["misses", "--db", db])
91
+ assert code == 0 and out.strip() == "No unparsed replies."
92
+ code, out, _ = await _run(capsys, ["misses", "--json", "--db", db])
93
+ assert code == 0 and out == ""
94
+
95
+
96
+ async def test_store_list_nudges(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
97
+ db = str(tmp_path / "t.db")
98
+ s1 = await _session(capsys, db, "miss-c", Prose("c"))
99
+ store = SQLiteStore(db_path=db)
100
+ rows = store.list_nudges()
101
+ assert len(rows) >= 2
102
+ assert all(t.event_type == "nudge" and s.id == s1 for s, t in rows)
103
+ ids = [t.id for _, t in rows]
104
+ assert ids == sorted(ids, reverse=True)
105
+ assert len(store.list_nudges(limit=1)) == 1
106
+ assert store.list_nudges(session_id="nope") == []
107
+ assert store.list_nudges(model="other/m") == []
108
+ assert len(store.list_nudges(session_id=s1, model="miss-c/m")) == len(rows)
@@ -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.1"
10
+ assert tether.__version__ == "0.2.2"
11
11
  for symbol in tether.__all__:
12
12
  assert hasattr(tether, symbol)
@@ -17,6 +17,7 @@ from tether.providers import (
17
17
  ModelResponse,
18
18
  ProviderResponse,
19
19
  extract_fallback_action,
20
+ register_dialect,
20
21
  register_provider,
21
22
  )
22
23
  from tether.sandbox import SandboxPolicy, SandboxViolationError
@@ -30,7 +31,7 @@ from tether.store import (
30
31
  ToolOffender,
31
32
  )
32
33
 
33
- __version__ = "0.2.1"
34
+ __version__ = "0.2.2"
34
35
 
35
36
  __all__ = [
36
37
  "ActionCall",
@@ -55,6 +56,7 @@ __all__ = [
55
56
  "extract_fallback_action",
56
57
  "load_pipeline",
57
58
  "parse_pipeline_yaml",
59
+ "register_dialect",
58
60
  "register_provider",
59
61
  "tool",
60
62
  ]