tau-core 0.2.0__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 (74) hide show
  1. tau_core-0.2.0/.gitignore +25 -0
  2. tau_core-0.2.0/LICENSE +21 -0
  3. tau_core-0.2.0/PKG-INFO +615 -0
  4. tau_core-0.2.0/README.md +588 -0
  5. tau_core-0.2.0/pyproject.toml +54 -0
  6. tau_core-0.2.0/src/tau_core/__init__.py +153 -0
  7. tau_core-0.2.0/src/tau_core/__main__.py +6 -0
  8. tau_core-0.2.0/src/tau_core/_version.py +10 -0
  9. tau_core-0.2.0/src/tau_core/agent.py +783 -0
  10. tau_core-0.2.0/src/tau_core/approval.py +513 -0
  11. tau_core-0.2.0/src/tau_core/builder.py +869 -0
  12. tau_core-0.2.0/src/tau_core/builtin_tools.py +37 -0
  13. tau_core-0.2.0/src/tau_core/channels/__init__.py +1 -0
  14. tau_core-0.2.0/src/tau_core/channels/terminal.py +533 -0
  15. tau_core-0.2.0/src/tau_core/cli.py +128 -0
  16. tau_core-0.2.0/src/tau_core/commands.py +89 -0
  17. tau_core-0.2.0/src/tau_core/compaction.py +201 -0
  18. tau_core-0.2.0/src/tau_core/config.py +479 -0
  19. tau_core-0.2.0/src/tau_core/events.py +93 -0
  20. tau_core-0.2.0/src/tau_core/hub/__init__.py +54 -0
  21. tau_core-0.2.0/src/tau_core/hub/cli.py +374 -0
  22. tau_core-0.2.0/src/tau_core/hub/control.py +238 -0
  23. tau_core-0.2.0/src/tau_core/hub/daemon.py +286 -0
  24. tau_core-0.2.0/src/tau_core/hub/launchd.py +164 -0
  25. tau_core-0.2.0/src/tau_core/hub/lock.py +101 -0
  26. tau_core-0.2.0/src/tau_core/hub/state.py +227 -0
  27. tau_core-0.2.0/src/tau_core/hub/templates/com.tau.hub.plist +48 -0
  28. tau_core-0.2.0/src/tau_core/hub/templates/tau-hub.service +30 -0
  29. tau_core-0.2.0/src/tau_core/hub/units.py +100 -0
  30. tau_core-0.2.0/src/tau_core/i18n.py +366 -0
  31. tau_core-0.2.0/src/tau_core/logs.py +88 -0
  32. tau_core-0.2.0/src/tau_core/models.py +265 -0
  33. tau_core-0.2.0/src/tau_core/persona.py +355 -0
  34. tau_core-0.2.0/src/tau_core/plugins.py +105 -0
  35. tau_core-0.2.0/src/tau_core/registry.py +385 -0
  36. tau_core-0.2.0/src/tau_core/sessions/__init__.py +52 -0
  37. tau_core-0.2.0/src/tau_core/sessions/base.py +77 -0
  38. tau_core-0.2.0/src/tau_core/sessions/file.py +400 -0
  39. tau_core-0.2.0/src/tau_core/sessions/memory.py +180 -0
  40. tau_core-0.2.0/src/tau_core/status.py +233 -0
  41. tau_core-0.2.0/src/tau_core/templates/dotenv.example +22 -0
  42. tau_core-0.2.0/src/tau_core/templates/models.toml +14 -0
  43. tau_core-0.2.0/src/tau_core/templates/persona.md +46 -0
  44. tau_core-0.2.0/src/tau_core/templates/tau.toml +64 -0
  45. tau_core-0.2.0/src/tau_core/templates/user.example.md +13 -0
  46. tau_core-0.2.0/src/tau_core/testing.py +258 -0
  47. tau_core-0.2.0/src/tau_core/tiers.py +159 -0
  48. tau_core-0.2.0/tests/conftest.py +49 -0
  49. tau_core-0.2.0/tests/test_agent.py +439 -0
  50. tau_core-0.2.0/tests/test_approval.py +633 -0
  51. tau_core-0.2.0/tests/test_builder.py +490 -0
  52. tau_core-0.2.0/tests/test_cli.py +219 -0
  53. tau_core-0.2.0/tests/test_compaction.py +196 -0
  54. tau_core-0.2.0/tests/test_config.py +318 -0
  55. tau_core-0.2.0/tests/test_hook_order.py +244 -0
  56. tau_core-0.2.0/tests/test_hub_control.py +146 -0
  57. tau_core-0.2.0/tests/test_hub_daemon.py +445 -0
  58. tau_core-0.2.0/tests/test_hub_lock.py +125 -0
  59. tau_core-0.2.0/tests/test_hub_service.py +355 -0
  60. tau_core-0.2.0/tests/test_hub_state.py +219 -0
  61. tau_core-0.2.0/tests/test_i18n.py +168 -0
  62. tau_core-0.2.0/tests/test_logs.py +58 -0
  63. tau_core-0.2.0/tests/test_loop_guard.py +201 -0
  64. tau_core-0.2.0/tests/test_models.py +220 -0
  65. tau_core-0.2.0/tests/test_persona.py +598 -0
  66. tau_core-0.2.0/tests/test_plugins.py +56 -0
  67. tau_core-0.2.0/tests/test_registry.py +259 -0
  68. tau_core-0.2.0/tests/test_repo_rules.py +79 -0
  69. tau_core-0.2.0/tests/test_resume.py +341 -0
  70. tau_core-0.2.0/tests/test_sessions.py +383 -0
  71. tau_core-0.2.0/tests/test_status.py +200 -0
  72. tau_core-0.2.0/tests/test_stream_contract.py +675 -0
  73. tau_core-0.2.0/tests/test_terminal_commands.py +376 -0
  74. tau_core-0.2.0/tests/test_tiers.py +524 -0
@@ -0,0 +1,25 @@
1
+ .DS_Store
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ *.local.env
6
+ __pycache__/
7
+ *.py[cod]
8
+
9
+ # Python / uv
10
+ .venv/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ dist/
14
+ build/
15
+ *.egg-info/
16
+
17
+ # Personal facts about the user stay local (persona/user.example.md is the tracked template)
18
+ persona/user.md
19
+
20
+ # Tools tau wrote itself, waiting for approval
21
+ tools/_pending/*
22
+ !tools/_pending/.gitkeep
23
+
24
+ # MkDocs build output
25
+ site/
tau_core-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fport
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,615 @@
1
+ Metadata-Version: 2.5
2
+ Name: tau-core
3
+ Version: 0.2.0
4
+ Summary: tau's agent core: build_agent, persona, tiers, roles, sessions and the tau CLI.
5
+ Project-URL: Homepage, https://github.com/fport/tau
6
+ Project-URL: Documentation, https://docs.tau.getporti.com
7
+ Project-URL: Issues, https://github.com/fport/tau/issues
8
+ Project-URL: Changelog, https://github.com/fport/tau/releases
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,assistant,cli,home-automation,llm,robot,strands,tau
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Home Automation
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: python-dotenv>=1.0
24
+ Requires-Dist: rich>=13
25
+ Requires-Dist: strands-agents[anthropic]<1.58,>=1.57
26
+ Description-Content-Type: text/markdown
27
+
28
+ # tau-core
29
+
30
+ The agent core of tau, a personal "smart body" agent platform: `build_agent`, the persona,
31
+ permission tiers, model roles, session stores and the `tau` command line.
32
+ It is built on [Strands Agents](https://strandsagents.com/) and is published on its own as
33
+ `tau-core` (import name `tau_core`).
34
+
35
+ ```sh
36
+ pip install tau-core # or, inside the tau repo: uv sync --all-packages --group dev
37
+ tau --help # chat, hub, components, doctor, init, version, plus plugin commands (tui, ...)
38
+ tau init # create ~/.tau/tau.toml and the persona files from the templates
39
+ tau doctor # check config, credentials, sessions, persona, .env, the hub
40
+ tau chat # talk to tau in the terminal
41
+ tau --lang tr chat # same, with Turkish UI chrome
42
+ ```
43
+
44
+ ```python
45
+ import asyncio
46
+ from tau_core import build_agent
47
+
48
+ agent = build_agent("home") # config from tau.toml + .env, model from the "brain" role
49
+ print(asyncio.run(agent.ask("Merhaba!")))
50
+ ```
51
+
52
+ `build_agent` makes no hub assumptions: no network of its own, no launchd, no globals. With
53
+ `tau_core.testing.FakeModel` it runs fully offline, which is how the test suite runs.
54
+
55
+ ## Configuration
56
+
57
+ `tau.toml` (under `TAU_HOME`) maps each model role to a provider and model id; code only ever
58
+ asks for a role (`brain`, `fast`, `local`). Credentials and roots come from the environment or
59
+ `TAU_HOME/.env` (never overriding variables that are already set): `TAU_HOME` (config root,
60
+ default: the current directory if it has a `tau.toml`, else `~/.tau`), `TAU_DATA_DIR` (mutable
61
+ state, default `~/.local/share/tau`), `TAU_PROFILE` (`home` | `travel` | `sport`, default
62
+ `home`), `TAU_ROLE` (`hub`|`node`), `TAU_HUB_PORT`, `TAU_LANG` (see below). Built-in providers:
63
+ `bedrock`, `anthropic` (with an optional per-role `fallback`) and `fake`; a `tau.providers`
64
+ component adds more (see [Components](#components)). A missing credential stops `tau chat` with
65
+ a message naming the variables; `tau doctor` reports it without starting anything.
66
+
67
+ ```toml
68
+ [models.roles.brain]
69
+ provider = "bedrock"
70
+ model_id = "..."
71
+ max_tokens = 4096
72
+ cache = { strategy = "auto", ttl = "1h" } # optional: Strands CacheConfig (bedrock, anthropic)
73
+
74
+ [agent]
75
+ max_turns = 12 # model calls per user turn (Limits(turns=...))
76
+ context = "sliding" # sliding | summarize: the model's window on a long session
77
+ window_size = 60 # sliding: messages kept; summarize: recent messages never summarized
78
+ max_total_tokens = 200000 # optional per-turn token budget (Limits(total_tokens=...))
79
+ ```
80
+
81
+ `context` picks Strands' `SlidingWindowConversationManager` or `SummarizingConversationManager`;
82
+ the session store still keeps every message, only what the model sees is windowed. A session
83
+ remembers the manager it was recorded with (Strands refuses to restore it under the other one),
84
+ so after changing `context` an earlier session fails to open with `Session <id> was recorded
85
+ with a different [agent] context setting; set it back to sliding or start a new session`
86
+ (`SessionContextMismatch`, a `ConfigError`) in `tau chat --session`/`--resume`, `tau tui
87
+ --resume`, `/resume` and the TUI's picker and sidebar alike; the current session is kept. The SDK's
88
+ `context_manager = "auto"`/`"agentic"` presets are rejected with a `ConfigError`: they register
89
+ untiered SDK tools that the tier gate would refuse. `cache` accepts the `CacheConfig` fields
90
+ (`strategy`, `ttl`, `system_prompt_ttl`, `tools_ttl`); the fake provider ignores it.
91
+
92
+ ## UI language and commands
93
+
94
+ Everything a person reads in a channel's chrome (commands, notices, the approval prompt, tier
95
+ labels, the TUI's titles and key hints) comes from one message table, `tau_core.i18n`, so the
96
+ terminal and the TUI never drift. The language is per installation:
97
+
98
+ ```toml
99
+ [ui]
100
+ language = "en" # en | tr
101
+ ```
102
+
103
+ `TAU_LANG` overrides the file, `tau --lang tr ...` overrides both (it is exported as `TAU_LANG`,
104
+ like `--profile`), and `/lang en|tr` switches it for one run. `Config.ui.language` is what a
105
+ channel reads. What the *model* reads (system-prompt scaffolding, tool descriptions, the gate's
106
+ refusal texts) is fixed English; errors raised before a `Config` exists (`ConfigError`,
107
+ `MissingCredentials`, hub messages, `tau hub status`) are plain English too. The persona itself
108
+ is Turkish by default and answers in the language the user writes.
109
+
110
+ Slash commands are shared by `tau chat` and `tau tui` (`tau_core.commands`); the Turkish aliases
111
+ are accepted forever but never shown in hints:
112
+
113
+ | command | aliases | meaning |
114
+ |---|---|---|
115
+ | `/help` | `/yardım`, `/yardim`, `/?` | list the commands with one-line help |
116
+ | `/tools` | `/araçlar`, `/araclar` | registered tools with tier and description |
117
+ | `/sessions` | `/oturumlar` | the 10 most recent earlier sessions, numbered |
118
+ | `/resume [n\|id]` | `/devam` | resume the most recent earlier session, the n-th of the last listing, or an id |
119
+ | `/new` | `/yeni` | start a fresh session |
120
+ | `/session` | `/oturum` | current session id, title, message count, store |
121
+ | `/compact [focus]` | `/özet`, `/ozet` | summarize this session into a new one and continue there; the old one is kept (see [Compaction](#compaction)) |
122
+ | `/clear` | `/temizle`, `/cls` | clear the screen; the session continues, nothing is deleted (`--resume` shows the history again) |
123
+ | `/status` | `/durum` | profile, session, model, context, tools, UI language, versions, hub |
124
+ | `/model [role]` | `/rol` | list the model roles, or switch this session to one (remembered by `/resume`) |
125
+ | `/lang en\|tr` | `/dil` | switch the UI language for this run |
126
+ | `/quit` | `/çık`, `/cik`, `/exit` | leave (`/exit` is an alias, `/quit` is the name the hints show) |
127
+
128
+ Matching is `str.casefold()` on the typed name (`/ARAÇLAR` works). In code:
129
+
130
+ ```python
131
+ from tau_core import LANGUAGES, SLASH_COMMANDS, command_hint, resolve_command, t
132
+
133
+ resolve_command("/Devam 2") # ("resume", "2"); None for "/x" or plain text
134
+ command_hint() # "/help, /tools, /sessions, /resume, /new, ..."
135
+ t("session.not_found", "tr", session="x") # "Oturum bulunamadı: x."
136
+ ```
137
+
138
+ `t(key, language, **kwargs)` formats with `str.format`, raises `KeyError` for an unknown key and
139
+ falls back to English for a language that lacks the key. Plugins add their own keys with
140
+ `tau_core.i18n.register_messages({"mine.hello": {"en": "...", "tr": "..."}})`.
141
+
142
+ ## Persona
143
+
144
+ The system prompt is rebuilt before every model call (a Strands `BeforeModelCallEvent` hook), so
145
+ an edit to a persona file applies on the next model call without a restart, even inside a tool
146
+ loop. Its layout is fixed:
147
+
148
+ ```text
149
+ <persona/persona.md>
150
+
151
+ ## User
152
+ <persona/user.md, or: No user file (persona/user.md). Address the user formally.>
153
+
154
+ ## Now
155
+ - Date and time: 29.09.2026 15:30, Tuesday (Europe/Istanbul)
156
+ - Profile: home
157
+ - Host role: hub
158
+ - Live nodes: unknown
159
+ - Budget today: unknown
160
+ ```
161
+
162
+ The scaffolding (headings, labels, `unknown`) is English because the model reads it; the
163
+ persona decides the reply language. The model receives the prompt as three content blocks
164
+ (`PersonaLoader.build_system_content`): the persona plus `## User` (stable), a `cachePoint`,
165
+ then `## Now` (changes every minute), so a provider that caches system prompts reuses the
166
+ stable part; `build_system_prompt` returns the same text as one string for tests and logging.
167
+
168
+ - `persona.md` is tau's character: Jarvis-style, Turkish by default and mirroring the language
169
+ the user writes in, polite, dry humor, at most two sentences for notifications and voice, no
170
+ jokes in approval requests, never inventing facts about the user. It is tracked, so it holds
171
+ nothing personal.
172
+ - `user.md` holds the facts about the user and how to address them. It is gitignored; start
173
+ from the made-up template with `cp persona/user.example.md persona/user.md`. Without it (or
174
+ when it is empty) tau addresses the user formally ("siz") and knows nothing about them.
175
+ - A file is re-read only when its `(st_mtime_ns, st_size)` changes. HTML comments
176
+ (`<!-- ... -->`) are notes for humans and never reach the model.
177
+ - A missing, empty or unreadable `persona.md` makes `build_agent` raise `ConfigError`. If the
178
+ file breaks later (deleted, truncated mid-save, not UTF-8), the last good version stays in
179
+ use until it is fixed; each such problem is logged once.
180
+ - Time is always Europe/Istanbul. A value no layer provides yet reads `unknown` (an empty
181
+ node list reads `none`), and the persona tells the model never to guess it.
182
+
183
+ Later layers fill the dynamic block through context sources: zero-argument callables that
184
+ return partial overrides (`nodes`, `budget`), merged in order. They run before every model
185
+ call, so they must return a cached value quickly. Unknown keys are ignored, `None` means
186
+ unknown, and a source that raises is skipped and logged once. Without an explicit
187
+ `context_sources` list, `build_agent` uses the enabled `tau.context` components (see
188
+ [Components](#components)); an explicit list, even an empty one, replaces them.
189
+
190
+ ```python
191
+ agent = build_agent(
192
+ context_sources=[
193
+ lambda: {"nodes": ["mac", "arm"]}, # live nodes (L5)
194
+ lambda: {"budget": "fast 0,12 $ / 1 $"}, # today's spend (L2)
195
+ ],
196
+ )
197
+ ```
198
+
199
+ ## Tiers and approval
200
+
201
+ Every tool declares a tier; a tool without one cannot be registered.
202
+
203
+ | Tier | Meaning | What happens |
204
+ |---|---|---|
205
+ | 0 `Tier.READ` | reads, no side effects | runs freely |
206
+ | 1 `Tier.DIGITAL_WRITE` | changes digital state (files, notes, messages) | runs and is logged |
207
+ | 2 `Tier.PHYSICAL` | acts on the physical world, or activates self-written code | waits for an approval |
208
+
209
+ ```python
210
+ from tau_core import Tier, tau_tool
211
+
212
+
213
+ @tau_tool(tier=Tier.PHYSICAL, effect="Switches the desk lamp on or off.")
214
+ def desk_lamp(state: str) -> str:
215
+ """Switches the desk lamp on or off.
216
+
217
+ Args:
218
+ state: "on" or "off".
219
+ """
220
+ ```
221
+
222
+ `effect` is the sentence approval prompts show (default: the tool description); write
223
+ descriptions and effects in English, the model reads them. A plain
224
+ Strands `@tool` has no tier: `ToolCatalog.add` raises `UntieredToolError`, `build_agent` refuses
225
+ it when passed in `tools=` and skips it with a warning when a plugin ships it. `tau_tool`
226
+ records the tier and effect in the tool spec (`tool_spec["annotations"]["tau"] = {"tier",
227
+ "effect"}`), so a `@tau_tool` method on a class keeps its tier when accessed through an
228
+ instance (Strands builds a new tool object for bound methods); `tier_of`/`effect_of` read the
229
+ annotation first and the older `tau_tier`/`tau_effect` attributes second.
230
+
231
+ Every call passes `TierGate` (`build_agent` wires it in through `TierHook`, registered at
232
+ `HookOrder.SDK_LAST` so the `tool_use` the gate sees is the one Strands runs, whatever other
233
+ hooks, interventions or plugins rewrite before it):
234
+
235
+ - **Unknown tool** (not in the catalog, looked up live): refused with
236
+ `Tool '{tool}' is not registered; it was not run.` and an `error` agent event.
237
+ - **Tier 0 and 1**: allowed.
238
+ - **Tier 2**: allowed when an exemption matches, otherwise the channel's `Approver` decides.
239
+ Anything but `Decision.APPROVED` (a rejection, a timeout, an approver that raises or answers
240
+ something invalid) refuses the call, and the model reads
241
+ `The user did not approve this action: {tool}. Do not perform it; tell the user briefly.` as
242
+ the tool result. Prompts are asked one at a time.
243
+ - **Every call of every tier** emits a `tool_call` agent event (`tool`, `tier`, `arguments`,
244
+ `status`, `result_summary`, `approval`, `duration_ms`, `tool_use_id`); every tier-2 question
245
+ emits an `approval` event. The log gets tier 0 at `DEBUG`, tiers 1 and 2 at `INFO` (with
246
+ arguments, result summary and approval; `tau chat` writes `INFO` and up to
247
+ `<data_dir>/logs/tau.log`), unknown tools at `WARNING`.
248
+
249
+ A channel (TUI, Telegram, TauBar) implements `Approver` and passes it to `build_agent`:
250
+
251
+ ```python
252
+ from tau_core import ApprovalRequest, Decision, build_agent
253
+
254
+
255
+ class ButtonApprover:
256
+ name = "telegram"
257
+ timeout_seconds = 600 # optional: stop waiting after 10 minutes and record TIMEOUT
258
+
259
+ async def decide(self, request: ApprovalRequest) -> Decision:
260
+ # show request.tool, request.arguments and request.effect; await the user's button
261
+ ...
262
+
263
+
264
+ agent = build_agent(approver=ButtonApprover(), channel="telegram")
265
+ ```
266
+
267
+ - Without an approver, `AutoRejectApprover` rejects every tier-2 call. No approver approves on
268
+ its own; tests script decisions with `tau_core.testing.ScriptedApprover([Decision.APPROVED])`
269
+ and use `UnansweredApprover` for timeouts.
270
+ - `TerminalApprover` (`tau chat`) prints the tool, its arguments as JSON and the effect in the
271
+ UI language (`format_approval_prompt(request, language)`), and approves only on `y`, `yes`,
272
+ `e` or `evet` whatever the language; anything else, Ctrl-D included, rejects. It has no
273
+ timeout: a blocked `input()` cannot be cancelled.
274
+ - **Timeouts:** `TierGate(timeout_seconds=...)`, else the approver's own `timeout_seconds`, else
275
+ none. When it runs out the question is cancelled and the decision is `timeout`; silence never
276
+ approves.
277
+ - **Exemptions** let registered routines (#037) skip per-call approval:
278
+ `build_agent(exemptions=[fn])` or `agent.gate.add_exemption(fn)` (returns an undo function).
279
+ `fn(tool_name, arguments)` sees a copy of the arguments and must return `True`; it is asked
280
+ only for tier-2 calls to known tools, one that raises does not exempt, and the call is still
281
+ recorded with `approval: "exempt"`.
282
+
283
+ `TierGate.check()` and `record()` do not need Strands, so the reflex engine and the MCP server
284
+ can use the same policy.
285
+
286
+ **Extra components.** `build_agent(hooks=[...], plugins=[...])` passes Strands `HookProvider`s
287
+ and `Plugin`s through to the `Agent`. The gate keeps the last word: `TierHook` runs at
288
+ `HookOrder.SDK_LAST`, no `BeforeToolCallEvent` callback may run after it (`build_agent`
289
+ checks the registry and raises `ConfigError` for a hook registered above `SDK_LAST`, since the
290
+ SDK accepts any order), and a tool a plugin registers behind the catalog's back is refused as
291
+ unknown (fail closed); a component that wants its tools to run ships them through `tau.tools`
292
+ with a tier. A hook added to the SDK agent *after* build cannot be refused; the gate then
293
+ records a refused call that ran, or an approved call whose arguments changed, as `error`
294
+ with an `error` event (`where: tier_gate`). When any registered tool is tier 2, `build_agent` uses the SDK's
295
+ `SequentialToolExecutor`, so two approved physical calls in one model turn never run at the
296
+ same time. Every span carries `trace_attributes` `tau.session_id`, `tau.channel`,
297
+ `tau.profile`.
298
+
299
+ ## Turns and stop reasons
300
+
301
+ `TauAgent.stream(prompt)` runs one user turn and yields `TurnEvent`s; `ask(prompt)` returns
302
+ only the text. Calls are serialized with an `asyncio.Lock` (created per event loop), so two
303
+ channels on one agent queue up instead of overlapping.
304
+
305
+ | event | meaning |
306
+ |---|---|
307
+ | `TextDelta(text)` | a piece of the reply |
308
+ | `ReasoningDelta(text)` | a piece of the model's extended thinking (SDK `reasoningText`) |
309
+ | `ToolUseStarted(tool_use_id, tool, tier)` | the model asked for a tool (`tier` is `None` for an unknown tool) |
310
+ | `ToolProgress(tool_use_id, tool, text)` | progress from a streaming tool (SDK `tool_stream_event`) |
311
+ | `ToolFinished(tool_use_id, tool, status, summary, approval)` | from the `tool_call` event the gate recorded |
312
+ | `Throttled(delay_seconds)` | the provider throttled the call; the SDK retries after the delay |
313
+ | `Notice(text)` | something the user should read, in the UI language |
314
+ | `TurnEnd(stop_reason)` | always the last event |
315
+
316
+ `to_turn_event(event)` is the pure mapping from the SDK's dict events (`data`,
317
+ `current_tool_use`, `reasoningText`, `tool_stream_event`, `event_loop_throttled_delay`,
318
+ `force_stop`); the terminal prints `Throttled` as a dim line and ignores reasoning and progress
319
+ for now. `TurnEnd.stop_reason` (`tau_core.STOP_REASONS`):
320
+
321
+ | stop reason | what happened | notice / event |
322
+ |---|---|---|
323
+ | `end_turn` | the model finished | – |
324
+ | `limit_turns` | `[agent] max_turns` model calls were used; the last call's tools still ran | `notice.turn_limit`, `turn_limit` event |
325
+ | `limit_total_tokens` | `[agent] max_total_tokens` was reached | `notice.total_tokens`, `turn_limit` event |
326
+ | `max_tokens` | the reply hit the provider's output limit; the partial reply is kept | `notice.max_tokens`, `notice` event |
327
+ | `cancelled` | `TauAgent.cancel()` stopped the turn at the SDK's next safe point; a cancel while no turn runs is ignored | `notice.cancelled`, `notice` event |
328
+ | `interrupt` | a hook kept interrupting after `max_turns` refusals; tau cleared the SDK's interrupt state | `notice.interrupt_refused`, `error` event |
329
+ | `error` | the model call failed (`notice.model_error`), the context window overflowed (`notice.overflow`) | `error` event with `where`: `model`, `context` or `interrupt` |
330
+ | `busy` | another invocation held the SDK's concurrency guard; nothing ran | `notice.busy`, no event |
331
+
332
+ tau never appends to the history by hand. When a turn leaves the history *dangling* - on a
333
+ `user` message (a failed model call, the loop guard or token budget after a tool result, a
334
+ cancel around tool execution, a session restored after a crash) or on an `assistant` message
335
+ with a `toolUse` and no result (the hub died while an approval was pending, or an interrupt
336
+ was given up) - the **next** `stream()` sends a two-message prompt to the SDK: an `assistant`
337
+ message with the last notice in parentheses (or "The previous turn did not finish." for a
338
+ crash-restored session), then the new `user` message. Both go through the SDK's own append
339
+ path, so they get tracking ids and reach the session store like every other message; for a
340
+ dangling `toolUse` the SDK first inserts its own "Tool was interrupted." result, so roles keep
341
+ alternating. The pending notice survives a `busy` turn (nothing was appended) and a window
342
+ trim (the check is by identity of the last message, not by length). A cancel during model
343
+ streaming needs no repair: the SDK stores its own "Cancelled by user" placeholder as the
344
+ reply. A hook that raises a Strands interrupt (`event.interrupt(...)`) gets
345
+ `{"tau": "rejected"}` as the answer inside the same turn, once per interrupt up to
346
+ `max_turns` refusals, with the turn's remaining `Limits` budget; the user sees
347
+ `notice.interrupt_refused`. If that does not clear it, or a session comes back from the store
348
+ in interrupt state, tau leaves the SDK's interrupt state itself and repairs on the next turn,
349
+ so the agent is never left stuck (interrupt-based approvals are issue 068).
350
+
351
+ ## Sessions
352
+
353
+ Every conversation is a session: its messages, its agent events (tool calls, approval
354
+ decisions, turn limits, errors) and its metadata (channel, profile, title = the first user
355
+ message, cut to 60 characters). Callers only use `SessionStore`, a Strands
356
+ `SessionRepository` plus `list_sessions`, `append_event`/`read_events`, `write_meta`/`read_meta`
357
+ and `event_sink`. `[sessions].backend` in `tau.toml` picks the backend and
358
+ `open_session_store(config)` builds it:
359
+
360
+ | Backend | Where | Notes |
361
+ |----------|----------------------------------------|-----------------------------------------|
362
+ | `memory` | the process | tests and throwaway runs |
363
+ | `file` | `TAU_DATA_DIR/sessions/<session id>/` | the default in the repo's `tau.toml` |
364
+ | `cloud` | D1 through the tau-cloud Worker API | issue 018; the hub holds no D1 secrets |
365
+
366
+ The `file` layout, one directory per session (ids look like `20260929-153012-3f9a`, UTC):
367
+
368
+ ```
369
+ session.json Strands session record; updated_at orders /sessions
370
+ meta.json channel, profile, title
371
+ events.jsonl one agent event per line, append-only
372
+ agents/tau/agent.json Strands agent state
373
+ agents/tau/messages/000000.json one file per message, in order
374
+ ```
375
+
376
+ JSON files are replaced atomically (temp file, fsync, rename), directories are `0700` and
377
+ files `0600`, symlinks and ids that are not plain names are refused. **Nothing is ever
378
+ deleted**: the store has no delete method and never overwrites a message; retention is "keep
379
+ everything".
380
+
381
+ In `tau chat`:
382
+
383
+ - `/sessions` lists the last 10 earlier sessions that have messages
384
+ (`n. id date channel title (N messages)`).
385
+ - `/resume` resumes the most recent earlier session and says which one; `/resume <n>` resumes
386
+ the n-th of the last `/sessions` listing (without a listing, of the one `/sessions` would
387
+ print now); `/resume <id>` resumes that session. `tau chat --resume [ID]` does the same at
388
+ start-up.
389
+ - `/new` starts a fresh session; `/session` shows the current id, title, message count and
390
+ backend; `/clear` erases the screen (an ANSI clear, then the ready banner again) and nothing
391
+ else: the session and the agent go on, and `--resume` shows the history again.
392
+ - `/status` prints the profile, the session (id, title, stored message count, role), the model
393
+ (role → provider, whether the role's `fallback` is in use, the model id; never a key), the
394
+ context mode and window with the in-memory message count, the tools (registered, how many
395
+ need approval), the UI language, the installed `tau-*` versions and whether a hub answers on
396
+ `http://127.0.0.1:<control_port>/health` (the `tau doctor` probe, one second, information
397
+ only).
398
+ - `/model` lists the roles of `tau.toml` with their providers and marks the current one;
399
+ `/model fast` rebuilds the agent on the **same** session with that role: the new agent is
400
+ built first (`build_agent(role=..., session_id=<current>)`, which restores the stored history
401
+ and takes over a pending repair notice), and only then is the previous one closed, so a
402
+ failed build changes nothing. Every session records the `role` it last ran on in its
403
+ metadata (`build_agent` writes it next to `channel` and `profile`), so `/resume` and
404
+ `tau chat --resume` come back on that role, also for sessions opened by `/new` and
405
+ `/compact`. `tau chat --role ROLE` pins the role for the run and wins over the stored one
406
+ (and is recorded in turn); an unknown role, missing credentials or an unavailable provider
407
+ leave the current agent unchanged and print why. `TauAgent.role` is the role an agent was
408
+ built for.
409
+
410
+ Resuming rebuilds the agent on the stored session (`build_agent(session_id=...,
411
+ session_store=...)`), so Strands restores `agent.messages`; the model sees the last
412
+ `[agent] window_size` messages (or a summary plus the recent ones), the store keeps them all.
413
+ A new backend implements the nine `SessionRepository` methods plus the five extras and arrives
414
+ as a `tau.sessions` entry point or through `register_session_backend(name, factory)` (see
415
+ [Components](#components)); `SESSION_BACKENDS` is the built-in seed.
416
+
417
+ ### Compaction
418
+
419
+ `/compact [focus]` is how a long conversation goes on without its whole history, while every
420
+ session stays on record:
421
+
422
+ 1. `tau_core.compaction.summarize(agent, focus=...)` runs a throwaway Strands `Agent` on the
423
+ agent's **own model** over a deep copy of `agent.messages` (no tools, no hooks, no session
424
+ manager, so the tier gate is not involved and the live agent is untouched), with an English
425
+ system prompt that asks for a continuation summary: facts about the user, decisions, open
426
+ tasks, tool results that still matter, and the language the user writes in; `focus` is
427
+ appended as `Focus on: ...`. An empty history, a failed model call or an empty reply raise
428
+ `CompactionError` and nothing changes.
429
+ 2. `seed_messages(summary, previous_session_id)` is the two-message history the new session
430
+ starts with: a user message `Summary of the previous conversation (session <id>):` plus the
431
+ summary, and the assistant's `Understood. I will continue from this summary.`.
432
+ 3. `build_agent(seed_messages=..., session_id=None)` opens the new session with that history.
433
+ The seed goes in as the Strands `Agent(messages=...)`; the SDK's `RepositorySessionManager`
434
+ writes every initial message to the store when it creates the agent record, so the seed is
435
+ part of the session and survives `--resume` (a test proves the file-store round trip).
436
+ 4. The new session's metadata gets `title` (the old title, or its first user line) and
437
+ `compacted_from = <old id>`; the old session gets `compacted_to = <new id>` and keeps every
438
+ message. `/sessions` lists both.
439
+
440
+ The channel then prints `Compacted N messages into a summary. New session <id>; the previous
441
+ session <old id> is kept.` and the summary itself. `N` is what was summarized: the messages in
442
+ memory, which the conversation window may have trimmed below the stored count.
443
+
444
+ ## Hub
445
+
446
+ `tau hub run` runs tau as a long-lived process in the hub role. Only one hub runs per machine
447
+ (`flock` on `<TAU_DATA_DIR>/hub/hub.lock`; a second start exits 1 with `tau hub is already
448
+ running (pid N)…`). While it runs it refreshes `hub/state.json` every `[hub].heartbeat_seconds`; SIGTERM
449
+ or SIGINT stops it cleanly and writes `stopped_at`. A start that finds a `state.json` without
450
+ `stopped_at` records a crash report (`crash_reports.jsonl`, `last_crash.json`); the reason comes
451
+ from `crash.json` when the dying hub could write one, else `unexpected exit (no clean shutdown)`.
452
+
453
+ ```sh
454
+ tau hub run # foreground; Ctrl-C or `tau hub stop` stops it
455
+ tau hub status # running or not, heartbeat, last crash, launchd state (--json for scripts)
456
+ tau hub stop # SIGTERM to the running hub; waits until it is gone
457
+ tau hub install # macOS: launchd user agent, restarted after a crash or kill
458
+ tau hub uninstall
459
+ tau hub systemd-unit # prints the systemd user unit (RPi5 hub)
460
+ ```
461
+
462
+ The control API listens on `127.0.0.1:<control_port>` only (default 7877, `TAU_HUB_PORT`
463
+ overrides) and speaks JSON: `GET /health` → `{"ok": true, "pid", "uptime_s"}`, `GET /status` →
464
+ `role`, `profile`, `version`, `started_at`, `heartbeat_at`, `uptime_s`, `last_crash`,
465
+ `approvals_pending`, `nodes`. Requests with a non-loopback `Host` header get 403.
466
+
467
+ Extending the hub: a `HubService` has a `name`, `async start(hub)` and `async stop()`; services
468
+ start in order after the crash check and stop in reverse. `tau hub run` starts the control API
469
+ first and then every enabled `tau.hub_services` component (see [Components](#components)): the
470
+ entry point is a `factory(config) -> HubService`, the service is *optional* (one that fails to
471
+ build or to start is logged and skipped, the hub runs on), and inside `start(hub)` it can call
472
+ `hub.add_status_source(fn)` to add keys to `/status` and
473
+ `hub.find_service("control-api").add_route(method, path, handler)` to add routes (handlers run
474
+ in the server thread; reach the event loop with `asyncio.run_coroutine_threadsafe(..., hub.loop)`).
475
+ Routes are additive only: `add_route` raises `ValueError` for a `(method, path)` that exists, so
476
+ no service can replace `GET /health`, `GET /status` or another service's route (the service
477
+ then fails to start and is skipped). `find_service` returns only services that have *started*:
478
+ `None` for one that failed to start, has not started yet or after the hub stopped, so a service
479
+ never wires into a half-initialised component. A service's own settings live in
480
+ `[component.<name>]` and are read with `config.component(name)`.
481
+
482
+ ```python
483
+ class TelegramRelay: # in another distribution
484
+ name = "telegram"
485
+
486
+ def __init__(self, config):
487
+ self.settings = config.component("telegram")
488
+
489
+ async def start(self, hub):
490
+ hub.add_status_source(lambda: {"telegram": {"connected": True}})
491
+ api = hub.find_service("control-api")
492
+ api.add_route("POST", "/telegram/send", self.send)
493
+
494
+ async def stop(self): ...
495
+
496
+
497
+ def make_relay(config): # [project.entry-points."tau.hub_services"] telegram = "pkg:make_relay"
498
+ return TelegramRelay(config)
499
+ ```
500
+
501
+ launchd is driven through the `Launchctl`
502
+ protocol; tests pass `FakeLaunchctl` to the CLI as `obj={"launchctl": fake}`. The launchd plist
503
+ and systemd unit are package templates; `infra/services/` in the tau repo has rendered examples
504
+ and the install notes.
505
+
506
+ ## Components
507
+
508
+ Every pluggable part of tau is a *component*: it reaches tau through a built-in seed
509
+ (`PROVIDERS`, `SESSION_BACKENDS`), an entry point in a `tau.*` group declared by any installed
510
+ distribution, or an in-process registration (`register_provider`, `register_session_backend`),
511
+ and `tau.toml` decides which discovered components are on (ADR 0005). `tau_core.registry` loads
512
+ them all with one rule: an entry point that fails to load, or loads the wrong kind of object,
513
+ is logged with its distribution name and skipped; it never breaks the CLI.
514
+
515
+ | group | entry point value | picked by |
516
+ |---|---|---|
517
+ | `tau.tools` | an `AgentTool` with a tier, a list of them, or a zero-argument factory | `build_agent` (all, minus `tools.disabled`) |
518
+ | `tau.commands` | a `click.Command`, mounted as `tau <name>` | the `tau` command |
519
+ | `tau.providers` | `factory(RoleConfig, env) -> Model`; optional `required_env = [["VAR", ...], ...]` on the factory (credential alternatives) | a role's `provider` in `tau.toml` |
520
+ | `tau.sessions` | `factory(Config) -> SessionStore` | `[sessions].backend` |
521
+ | `tau.context` | `factory(Config) -> ContextSource` (a callable returning `{"nodes": ..., "budget": ...}`) | `build_agent` when no `context_sources` are passed |
522
+ | `tau.hub_services` | `factory(Config) -> HubService` | `tau hub run`, after the control API |
523
+
524
+ `tau.channels` stays reserved for hub-hosted channels. A plugin package declares what it ships:
525
+
526
+ ```toml
527
+ [project.entry-points."tau.tools"]
528
+ mine = "my_package:TOOLS" # tools carry a tier (tau_tool); untiered ones are skipped
529
+
530
+ [project.entry-points."tau.commands"]
531
+ mine = "my_package.cli:command" # `tau mine`
532
+
533
+ [project.entry-points."tau.providers"]
534
+ ollama = "my_package.models:make_model" # make_model(role_cfg, env) -> Model; make_model.required_env = []
535
+
536
+ [project.entry-points."tau.sessions"]
537
+ cloud = "my_package.sessions:make_store" # make_store(config) -> SessionStore
538
+
539
+ [project.entry-points."tau.context"]
540
+ nodes = "my_package.mesh:make_source" # make_source(config) -> callable returning {"nodes": [...]}
541
+
542
+ [project.entry-points."tau.hub_services"]
543
+ telegram = "my_package.relay:make_relay" # make_relay(config) -> HubService
544
+ ```
545
+
546
+ A seed name wins over an entry point with the same name (an installed package cannot replace
547
+ `bedrock` silently); `register_provider` / `register_session_backend` replace anything, since
548
+ they are explicit code. `Config.load` validates provider and backend *names* without loading a
549
+ single entry point, so `tau version` never imports a provider SDK.
550
+
551
+ Switch discovered components on or off in `tau.toml` without uninstalling them:
552
+
553
+ ```toml
554
+ [components]
555
+ context = [] # enabled tau.context sources; empty = every discovered one
556
+ [components.tools]
557
+ disabled = ["demo_physical_action", "dist:tau-example-tool"] # tool names or dist:<distribution>
558
+ [components.hub]
559
+ services = [] # enabled tau.hub_services; empty = every discovered one
560
+
561
+ [component.telegram] # a component's own settings: config.component("telegram")
562
+ chat_id_env = "TELEGRAM_CHAT_ID"
563
+ ```
564
+
565
+ `tools.disabled` can only remove tools; it never adds one, gives one a tier or changes a tier,
566
+ and an explicit `build_agent(tools=[...])` bypasses it. Any other top-level table in `tau.toml`
567
+ is logged once and ignored, never a failure. Nothing in `[components]` can replace the tier
568
+ gate, the approver or the catalog: a tool that reaches the Strands agent behind the catalog's
569
+ back (through a `Plugin`, for instance) is refused as unknown.
570
+
571
+ Three commands show, check and set up an installation (English, `--json` where useful):
572
+
573
+ ```sh
574
+ tau components # every component in every group: distribution, version, enabled/disabled, load error
575
+ tau doctor # config, per-role provider + credentials (no client built), session store, persona
576
+ # files, .env names vs .env.example, component load errors, [components] names that
577
+ # no installed component provides, control API reachable; exit 1 when a check fails
578
+ # ("warn" for a planned provider such as ollama and for the unknown names)
579
+ tau init # tau.toml from the shipped template (provider: bedrock > anthropic > fake, by the
580
+ # credentials present in the environment or <home>/.env), persona/persona.md,
581
+ # user.example.md, user.md (from the example unless it exists) and .env.example;
582
+ # asks the UI language unless --language or --yes
583
+ tau init --home ~/.tau --provider anthropic --language tr --profile home --yes # non-interactive
584
+ ```
585
+
586
+ `tau init` never overwrites an existing `tau.toml` or `persona.md` without `--force` and never
587
+ overwrites `user.md` or `.env.example` (the latter is the list of names `tau doctor` checks
588
+ `.env` against; add your own names there); `--profile` appends `TAU_PROFILE` to `<home>/.env`
589
+ when it is not set there. The shipped templates equal the repository's `tau.toml`,
590
+ `persona/persona.md` and `persona/user.example.md` (a test keeps them identical); the
591
+ `.env.example` template is the repository's file without its L0 remote-access section. A
592
+ `tau.providers` entry point that is declared but does not load is reported as `Model provider
593
+ 'x' from <distribution> failed to load: ...` by `tau doctor`, `tau chat` and `tau components`
594
+ alike; only a name no distribution declares is an `Unknown model provider`.
595
+
596
+ ## Logging
597
+
598
+ `configure_logging(logs_dir, verbose=False)` sends `WARNING`s (or `INFO` with `verbose`) to
599
+ stderr as one-line messages and, when `logs_dir` is given, `INFO` and up with tracebacks to
600
+ `<logs_dir>/tau.log` (`tau chat` and `tau hub run` use `config.logs_dir`); it returns the log
601
+ file path. `reset_logging()` removes those handlers again. Both are part of the public API so a
602
+ channel in another package (the TUI, Telegram) writes to the same file; a full-screen UI that
603
+ owns the terminal passes `console=False` to keep stderr silent while the file log still
604
+ receives every record.
605
+
606
+ ## Development
607
+
608
+ ```sh
609
+ uv sync --all-packages --group dev
610
+ uv run pytest -q
611
+ uv run ruff check . && uv run ruff format .
612
+ uv build --package tau-core
613
+ ```
614
+
615
+ License: MIT.