bub 0.3.3__tar.gz → 0.3.4__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 (63) hide show
  1. bub-0.3.4/.gitignore +147 -0
  2. {bub-0.3.3 → bub-0.3.4}/PKG-INFO +17 -16
  3. {bub-0.3.3 → bub-0.3.4}/pyproject.toml +34 -27
  4. {bub-0.3.3 → bub-0.3.4}/src/bub/__init__.py +1 -1
  5. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/agent.py +152 -47
  6. bub-0.3.4/src/bub/builtin/cli.py +201 -0
  7. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/hook_impl.py +6 -2
  8. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/tools.py +8 -2
  9. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/base.py +12 -0
  10. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/cli/__init__.py +37 -14
  11. bub-0.3.4/src/bub/channels/cli/renderer.py +82 -0
  12. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/manager.py +18 -3
  13. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/telegram.py +4 -0
  14. {bub-0.3.3 → bub-0.3.4}/src/bub/framework.py +32 -14
  15. {bub-0.3.3 → bub-0.3.4}/src/bub/hook_runtime.py +15 -4
  16. {bub-0.3.3 → bub-0.3.4}/src/bub/hookspecs.py +7 -2
  17. {bub-0.3.3 → bub-0.3.4}/src/bub/types.py +4 -1
  18. {bub-0.3.3 → bub-0.3.4}/src/skills/telegram/SKILL.md +15 -25
  19. {bub-0.3.3 → bub-0.3.4}/src/skills/telegram/scripts/telegram_send.py +5 -29
  20. {bub-0.3.3 → bub-0.3.4}/tests/test_builtin_agent.py +27 -10
  21. {bub-0.3.3 → bub-0.3.4}/tests/test_builtin_hook_impl.py +15 -5
  22. {bub-0.3.3 → bub-0.3.4}/tests/test_channels.py +27 -10
  23. {bub-0.3.3 → bub-0.3.4}/tests/test_subagent_tool.py +8 -1
  24. bub-0.3.3/src/bub/builtin/cli.py +0 -83
  25. bub-0.3.3/src/bub/channels/cli/renderer.py +0 -46
  26. {bub-0.3.3 → bub-0.3.4}/LICENSE +0 -0
  27. {bub-0.3.3 → bub-0.3.4}/README.md +0 -0
  28. {bub-0.3.3 → bub-0.3.4}/src/bub/__main__.py +0 -0
  29. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/__init__.py +0 -0
  30. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/auth.py +0 -0
  31. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/context.py +0 -0
  32. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/settings.py +0 -0
  33. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/shell_manager.py +0 -0
  34. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/store.py +0 -0
  35. {bub-0.3.3 → bub-0.3.4}/src/bub/builtin/tape.py +0 -0
  36. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/__init__.py +0 -0
  37. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/handler.py +0 -0
  38. {bub-0.3.3 → bub-0.3.4}/src/bub/channels/message.py +0 -0
  39. {bub-0.3.3 → bub-0.3.4}/src/bub/envelope.py +0 -0
  40. {bub-0.3.3 → bub-0.3.4}/src/bub/skills.py +0 -0
  41. {bub-0.3.3 → bub-0.3.4}/src/bub/tools.py +0 -0
  42. {bub-0.3.3 → bub-0.3.4}/src/bub/utils.py +0 -0
  43. {bub-0.3.3 → bub-0.3.4}/src/skills/README.md +0 -0
  44. {bub-0.3.3 → bub-0.3.4}/src/skills/gh/SKILL.md +0 -0
  45. {bub-0.3.3 → bub-0.3.4}/src/skills/skill-creator/SKILL.md +0 -0
  46. {bub-0.3.3 → bub-0.3.4}/src/skills/skill-creator/license.txt +0 -0
  47. {bub-0.3.3 → bub-0.3.4}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  48. {bub-0.3.3 → bub-0.3.4}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  49. {bub-0.3.3 → bub-0.3.4}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  50. {bub-0.3.3 → bub-0.3.4}/tests/test_builtin_cli.py +0 -0
  51. {bub-0.3.3 → bub-0.3.4}/tests/test_builtin_tools.py +0 -0
  52. {bub-0.3.3 → bub-0.3.4}/tests/test_cli_help.py +0 -0
  53. {bub-0.3.3 → bub-0.3.4}/tests/test_envelope.py +0 -0
  54. {bub-0.3.3 → bub-0.3.4}/tests/test_file_tape_store_entry_ids.py +0 -0
  55. {bub-0.3.3 → bub-0.3.4}/tests/test_fork_store_merge_back.py +0 -0
  56. {bub-0.3.3 → bub-0.3.4}/tests/test_framework.py +0 -0
  57. {bub-0.3.3 → bub-0.3.4}/tests/test_hook_runtime.py +0 -0
  58. {bub-0.3.3 → bub-0.3.4}/tests/test_image_message.py +0 -0
  59. {bub-0.3.3 → bub-0.3.4}/tests/test_settings.py +0 -0
  60. {bub-0.3.3 → bub-0.3.4}/tests/test_skills.py +0 -0
  61. {bub-0.3.3 → bub-0.3.4}/tests/test_tape_search_output.py +0 -0
  62. {bub-0.3.3 → bub-0.3.4}/tests/test_tools.py +0 -0
  63. {bub-0.3.3 → bub-0.3.4}/tests/test_utils.py +0 -0
bub-0.3.4/.gitignore ADDED
@@ -0,0 +1,147 @@
1
+ docs/source
2
+
3
+ # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
4
+
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ share/python-wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+ MANIFEST
32
+
33
+ # PyInstaller
34
+ # Usually these files are written by a python script from a template
35
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
+ *.manifest
37
+ *.spec
38
+
39
+ # Installer logs
40
+ pip-log.txt
41
+ pip-delete-this-directory.txt
42
+
43
+ # Unit test / coverage reports
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+ .coverage
48
+ .coverage.*
49
+ .cache
50
+ nosetests.xml
51
+ coverage.xml
52
+ *.cover
53
+ *.py,cover
54
+ .hypothesis/
55
+ .pytest_cache/
56
+ cover/
57
+
58
+ # Translations
59
+ *.mo
60
+ *.pot
61
+
62
+ # Django stuff:
63
+ *.log
64
+ local_settings.py
65
+ db.sqlite3
66
+ db.sqlite3-journal
67
+
68
+ # Flask stuff:
69
+ instance/
70
+ .webassets-cache
71
+
72
+ # Scrapy stuff:
73
+ .scrapy
74
+
75
+ # Sphinx documentation
76
+ docs/_build/
77
+
78
+ # PyBuilder
79
+ .pybuilder/
80
+ target/
81
+
82
+ # Jupyter Notebook
83
+ .ipynb_checkpoints
84
+
85
+ # IPython
86
+ profile_default/
87
+ ipython_config.py
88
+
89
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
90
+ __pypackages__/
91
+
92
+ # Celery stuff
93
+ celerybeat-schedule
94
+ celerybeat.pid
95
+
96
+ # SageMath parsed files
97
+ *.sage.py
98
+
99
+ # Environments
100
+ .env
101
+ .venv
102
+ env/
103
+ venv/
104
+ ENV/
105
+ env.bak/
106
+ venv.bak/
107
+ .pdm-python
108
+
109
+ # Spyder project settings
110
+ .spyderproject
111
+ .spyproject
112
+
113
+ # Rope project settings
114
+ .ropeproject
115
+
116
+ # mkdocs documentation
117
+ /site
118
+
119
+ # mypy
120
+ .mypy_cache/
121
+ .dmypy.json
122
+ dmypy.json
123
+
124
+ # Pyre type checker
125
+ .pyre/
126
+
127
+ # pytype static type analyzer
128
+ .pytype/
129
+
130
+ # Cython debug symbols
131
+ cython_debug/
132
+
133
+ # Vscode config files
134
+ .vscode/
135
+
136
+ # PyCharm
137
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
138
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
139
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
140
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
141
+ #.idea/
142
+
143
+ # Reference directory - ignore all reference projects
144
+ reference/
145
+
146
+ # Local legacy backups created during framework migrations
147
+ backup/
@@ -1,8 +1,12 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: A common shape for agents that live alongside people.
5
- Author-Email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
5
+ Project-URL: Homepage, https://bub.build
6
+ Project-URL: Repository, https://github.com/bubbuild/bub
7
+ Project-URL: Documentation, https://bub.build
8
+ Author-email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
9
+ License-File: LICENSE
6
10
  Classifier: Intended Audience :: Developers
7
11
  Classifier: Programming Language :: Python
8
12
  Classifier: Programming Language :: Python :: 3
@@ -10,25 +14,22 @@ Classifier: Programming Language :: Python :: 3.12
10
14
  Classifier: Programming Language :: Python :: 3.13
11
15
  Classifier: Programming Language :: Python :: 3.14
12
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
- Project-URL: Homepage, https://bub.build
14
- Project-URL: Repository, https://github.com/bubbuild/bub
15
- Project-URL: Documentation, https://bub.build
16
17
  Requires-Python: <4.0,>=3.12
17
- Requires-Dist: pydantic>=2.0.0
18
- Requires-Dist: pydantic-settings>=2.0.0
19
- Requires-Dist: pyyaml>=6.0.0
20
- Requires-Dist: pluggy>=1.6.0
21
- Requires-Dist: typer>=0.9.0
22
- Requires-Dist: republic>=0.5.4
18
+ Requires-Dist: aiohttp>=3.13.3
23
19
  Requires-Dist: any-llm-sdk[anthropic]
24
- Requires-Dist: rich>=13.0.0
20
+ Requires-Dist: loguru>=0.7.2
21
+ Requires-Dist: pluggy>=1.6.0
25
22
  Requires-Dist: prompt-toolkit>=3.0.0
23
+ Requires-Dist: pydantic-settings>=2.0.0
24
+ Requires-Dist: pydantic>=2.0.0
26
25
  Requires-Dist: python-telegram-bot>=21.0
27
- Requires-Dist: loguru>=0.7.2
26
+ Requires-Dist: pyyaml>=6.0.0
28
27
  Requires-Dist: rapidfuzz>=3.14.3
29
- Requires-Dist: aiohttp>=3.13.3
28
+ Requires-Dist: republic>=0.5.4
29
+ Requires-Dist: rich>=13.0.0
30
+ Requires-Dist: typer>=0.9.0
30
31
  Provides-Extra: logfire
31
- Requires-Dist: logfire>=4.31.0; extra == "logfire"
32
+ Requires-Dist: logfire>=4.31.0; extra == 'logfire'
32
33
  Description-Content-Type: text/markdown
33
34
 
34
35
  # Bub
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bub"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "A common shape for agents that live alongside people."
5
5
  authors = [
6
6
  { name = "Chojan Shang", email = "psiace@apache.org" },
@@ -18,6 +18,7 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.14",
19
19
  "Topic :: Software Development :: Libraries :: Python Modules",
20
20
  ]
21
+
21
22
  dependencies = [
22
23
  "pydantic>=2.0.0",
23
24
  "pydantic-settings>=2.0.0",
@@ -63,30 +64,22 @@ dev = [
63
64
  ]
64
65
 
65
66
  [build-system]
66
- requires = [
67
- "pdm-backend",
68
- ]
69
- build-backend = "pdm.backend"
67
+ requires = ["hatchling"]
68
+ build-backend = "hatchling.build"
70
69
 
71
- [tool.pdm.build]
72
- includes = [
73
- "src/bub",
74
- "src/skills",
75
- ]
70
+ [tool.hatch.build.targets.sdist]
71
+ only-include = ["src/bub", "src/skills", "tests"]
72
+
73
+ [tool.hatch.build.targets.wheel]
74
+ sources = ["src"]
75
+ only-include = ["src/bub", "src/skills"]
76
76
 
77
77
  [tool.vulture]
78
- ignore_names = [
79
- "test_*",
80
- "Test*",
81
- ]
82
- paths = [
83
- "src",
84
- ]
78
+ ignore_names = ["test_*", "Test*"]
79
+ paths = ["src"]
85
80
 
86
81
  [tool.mypy]
87
- files = [
88
- "src",
89
- ]
82
+ files = ["src"]
90
83
  disallow_untyped_defs = false
91
84
  disallow_any_unimported = false
92
85
  no_implicit_optional = true
@@ -100,48 +93,62 @@ exclude = [
100
93
  ]
101
94
 
102
95
  [tool.pytest.ini_options]
103
- testpaths = [
104
- "tests",
105
- ]
96
+ testpaths = ["tests"]
106
97
 
107
98
  [tool.ruff]
108
99
  target-version = "py312"
109
100
  line-length = 120
110
101
  fix = true
111
102
  extend-exclude = [
112
- "src/skills/**/scripts/*",
103
+ "src/skills/**/scripts/*"
113
104
  ]
114
105
 
115
106
  [tool.ruff.lint]
116
107
  select = [
108
+ # flake8-2020
117
109
  "YTT",
110
+ # flake8-bandit
118
111
  "S",
112
+ # flake8-bugbear
119
113
  "B",
114
+ # flake8-builtins
120
115
  "A",
116
+ # flake8-comprehensions
121
117
  "C4",
118
+ # flake8-debugger
122
119
  "T10",
120
+ # flake8-simplify
123
121
  "SIM",
122
+ # isort
124
123
  "I",
124
+ # mccabe
125
125
  "C90",
126
+ # pycodestyle
126
127
  "E",
127
128
  "W",
129
+ # pyflakes
128
130
  "F",
131
+ # pygrep-hooks
129
132
  "PGH",
133
+ # pyupgrade
130
134
  "UP",
135
+ # ruff
131
136
  "RUF",
137
+ # tryceratops
132
138
  "TRY",
133
139
  ]
134
140
  ignore = [
141
+ # LineTooLong
135
142
  "E501",
143
+ # DoNotAssignLambda
136
144
  "E731",
145
+ # Avoid noisy exception-message rule for CLI/tool errors
137
146
  "TRY003",
138
147
  "S603",
139
148
  ]
140
149
 
141
150
  [tool.ruff.lint.per-file-ignores]
142
- "tests/*" = [
143
- "S101",
144
- ]
151
+ "tests/*" = ["S101"]
145
152
 
146
153
  [tool.ruff.format]
147
154
  preview = true
@@ -5,4 +5,4 @@ from bub.hookspecs import hookimpl
5
5
  from bub.tools import tool
6
6
 
7
7
  __all__ = ["BubFramework", "hookimpl", "tool"]
8
- __version__ = "0.3.3"
8
+ __version__ = "0.3.4"
@@ -7,7 +7,8 @@ import inspect
7
7
  import re
8
8
  import shlex
9
9
  import time
10
- from collections.abc import Collection
10
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Collection, Coroutine, Iterable
11
+ from contextlib import AsyncExitStack
11
12
  from dataclasses import dataclass, replace
12
13
  from datetime import UTC, datetime
13
14
  from functools import cached_property
@@ -15,7 +16,15 @@ from pathlib import Path
15
16
  from typing import Any
16
17
 
17
18
  from loguru import logger
18
- from republic import LLM, AsyncTapeStore, TapeContext, ToolAutoResult, ToolContext
19
+ from republic import (
20
+ LLM,
21
+ AsyncStreamEvents,
22
+ AsyncTapeStore,
23
+ StreamEvent,
24
+ StreamState,
25
+ TapeContext,
26
+ ToolContext,
27
+ )
19
28
  from republic.tape import InMemoryTapeStore, Tape
20
29
 
21
30
  from bub.builtin.settings import AgentSettings, load_settings
@@ -29,6 +38,11 @@ from bub.utils import workspace_from_state
29
38
 
30
39
  CONTINUE_PROMPT = "Continue the task."
31
40
  HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
41
+ _CONTEXT_LENGTH_PATTERNS = re.compile(
42
+ r"context.{0,20}length|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
43
+ re.IGNORECASE,
44
+ )
45
+ MAX_AUTO_HANDOFF_RETRIES = 1
32
46
 
33
47
 
34
48
  class Agent:
@@ -47,6 +61,25 @@ class Agent:
47
61
  llm = _build_llm(self.settings, tape_store, self.framework.build_tape_context())
48
62
  return TapeService(llm, self.settings.home / "tapes", tape_store)
49
63
 
64
+ @staticmethod
65
+ def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents:
66
+ async def generator() -> AsyncIterator:
67
+ for item in iterable:
68
+ yield item
69
+
70
+ return AsyncStreamEvents(generator())
71
+
72
+ @staticmethod
73
+ def _events_with_callback(
74
+ events: AsyncStreamEvents, callback: Callable[[], Coroutine[Any, Any, Any]]
75
+ ) -> AsyncStreamEvents:
76
+ async def generator() -> AsyncIterator[StreamEvent]:
77
+ async for event in events:
78
+ yield event
79
+ await callback()
80
+
81
+ return AsyncStreamEvents(generator(), state=events._state)
82
+
50
83
  async def run(
51
84
  self,
52
85
  *,
@@ -56,19 +89,33 @@ class Agent:
56
89
  model: str | None = None,
57
90
  allowed_skills: Collection[str] | None = None,
58
91
  allowed_tools: Collection[str] | None = None,
59
- ) -> str:
92
+ ) -> AsyncStreamEvents:
60
93
  if not prompt:
61
- return "error: empty prompt"
94
+ events = [
95
+ StreamEvent("text", {"delta": "error: empty prompt"}),
96
+ StreamEvent("final", {"text": "error: empty prompt", "ok": False}),
97
+ ]
98
+ return self._events_from_iterable(events)
99
+
62
100
  tape = self.tapes.session_tape(session_id, workspace_from_state(state))
63
101
  tape.context = replace(tape.context, state=state)
64
102
  merge_back = not session_id.startswith("temp/")
65
- async with self.tapes.fork_tape(tape.name, merge_back=merge_back):
66
- await self.tapes.ensure_bootstrap_anchor(tape.name)
67
- if isinstance(prompt, str) and prompt.strip().startswith(","):
68
- return await self._run_command(tape=tape, line=prompt.strip())
69
- return await self._agent_loop(
103
+ stack = AsyncExitStack()
104
+ # the fork_tape context manager must not be exited until the last chunk of the stream is consumed.
105
+ # So we use an AsyncExitStack and inject a callback to the iterator.
106
+ await stack.enter_async_context(self.tapes.fork_tape(tape.name, merge_back=merge_back))
107
+ await self.tapes.ensure_bootstrap_anchor(tape.name)
108
+ if isinstance(prompt, str) and prompt.strip().startswith(","):
109
+ result = await self._run_command(tape=tape, line=prompt.strip())
110
+ events = self._events_from_iterable([
111
+ StreamEvent("text", {"delta": result}),
112
+ StreamEvent("final", {"text": result, "ok": True}),
113
+ ])
114
+ else:
115
+ events = await self._agent_loop(
70
116
  tape=tape, prompt=prompt, model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools
71
117
  )
118
+ return self._events_with_callback(events, callback=stack.aclose)
72
119
 
73
120
  async def _run_command(self, tape: Tape, *, line: str) -> str:
74
121
  line = line[1:].strip()
@@ -118,7 +165,7 @@ class Agent:
118
165
  model: str | None = None,
119
166
  allowed_skills: Collection[str] | None = None,
120
167
  allowed_tools: Collection[str] | None = None,
121
- ) -> str:
168
+ ) -> AsyncStreamEvents:
122
169
  next_prompt: str | list[dict] = prompt
123
170
  display_model = model or self.settings.model
124
171
  await self.tapes.append_event(
@@ -131,34 +178,61 @@ class Agent:
131
178
  "allowed_tools": list(allowed_tools) if allowed_tools else None,
132
179
  },
133
180
  )
181
+ state = StreamState()
182
+ iterator = self._stream_events_with_auto_handoff(
183
+ tape=tape,
184
+ prompt=next_prompt,
185
+ state=state,
186
+ model=model,
187
+ allowed_skills=allowed_skills,
188
+ allowed_tools=allowed_tools,
189
+ )
190
+ return AsyncStreamEvents(iterator, state=state)
191
+
192
+ async def _stream_events_with_auto_handoff(
193
+ self,
194
+ tape: Tape,
195
+ prompt: str | list[dict],
196
+ state: StreamState,
197
+ model: str | None = None,
198
+ allowed_skills: Collection[str] | None = None,
199
+ allowed_tools: Collection[str] | None = None,
200
+ ) -> AsyncGenerator[StreamEvent, None]:
201
+ auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES
202
+ display_model = model or self.settings.model
203
+ next_prompt = prompt
134
204
  for step in range(1, self.settings.max_steps + 1):
135
205
  start = time.monotonic()
206
+ outcome = _ToolAutoOutcome(kind="text", text="", error="")
136
207
  logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model)
137
208
  await self.tapes.append_event(tape.name, "loop.step.start", {"step": step, "prompt": next_prompt})
138
- try:
139
- output = await self._run_tools_once(
140
- tape=tape,
141
- prompt=next_prompt,
142
- model=model,
143
- allowed_skills=allowed_skills,
144
- allowed_tools=allowed_tools,
145
- )
146
- except Exception as exc:
147
- elapsed_ms = int((time.monotonic() - start) * 1000)
148
- await self.tapes.append_event(
149
- tape.name,
150
- "loop.step",
151
- {
152
- "step": step,
153
- "elapsed_ms": elapsed_ms,
154
- "status": "error",
155
- "error": f"{exc!s}",
156
- "date": datetime.now(UTC).isoformat(),
157
- },
158
- )
159
- raise
160
-
161
- outcome = _resolve_tool_auto_result(output)
209
+ output = await self._run_once(
210
+ tape=tape,
211
+ prompt=next_prompt,
212
+ model=model,
213
+ allowed_skills=allowed_skills,
214
+ allowed_tools=allowed_tools,
215
+ )
216
+ async for event in output:
217
+ yield event
218
+ if event.kind == "error":
219
+ elapsed_ms = int((time.monotonic() - start) * 1000)
220
+ await self.tapes.append_event(
221
+ tape.name,
222
+ "loop.step",
223
+ {
224
+ "step": step,
225
+ "elapsed_ms": elapsed_ms,
226
+ "status": "error",
227
+ "error": event.data.get("message", ""),
228
+ "date": datetime.now(UTC).isoformat(),
229
+ },
230
+ )
231
+ elif event.kind == "final":
232
+ outcome = _resolve_tool_auto_result(event.data)
233
+
234
+ state.error = output.error
235
+ state.usage = output.usage
162
236
  elapsed_ms = int((time.monotonic() - start) * 1000)
163
237
  if outcome.kind == "text":
164
238
  await self.tapes.append_event(
@@ -171,7 +245,7 @@ class Agent:
171
245
  "date": datetime.now(UTC).isoformat(),
172
246
  },
173
247
  )
174
- return outcome.text
248
+ return
175
249
  if outcome.kind == "continue":
176
250
  if "context" in tape.context.state:
177
251
  next_prompt = f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
@@ -188,6 +262,35 @@ class Agent:
188
262
  },
189
263
  )
190
264
  continue
265
+
266
+ # Check if this is a context-length error that can be recovered via auto-handoff
267
+ if auto_handoff_remaining > 0 and _is_context_length_error(outcome.error):
268
+ auto_handoff_remaining -= 1
269
+ logger.warning(
270
+ "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}",
271
+ tape.name,
272
+ step,
273
+ )
274
+ await self.tapes.handoff(
275
+ tape.name,
276
+ name="auto_handoff/context_overflow",
277
+ state={"reason": "context_length_exceeded", "error": outcome.error},
278
+ )
279
+ await self.tapes.append_event(
280
+ tape.name,
281
+ "loop.step",
282
+ {
283
+ "step": step,
284
+ "elapsed_ms": elapsed_ms,
285
+ "status": "auto_handoff",
286
+ "error": outcome.error,
287
+ "date": datetime.now(UTC).isoformat(),
288
+ },
289
+ )
290
+ # Retry with original prompt — the handoff anchor will truncate history
291
+ next_prompt = prompt
292
+ continue
293
+
191
294
  await self.tapes.append_event(
192
295
  tape.name,
193
296
  "loop.step",
@@ -212,7 +315,7 @@ class Agent:
212
315
  expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys())
213
316
  return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills)
214
317
 
215
- async def _run_tools_once(
318
+ async def _run_once(
216
319
  self,
217
320
  *,
218
321
  tape: Tape,
@@ -220,7 +323,7 @@ class Agent:
220
323
  model: str | None = None,
221
324
  allowed_tools: Collection[str] | None = None,
222
325
  allowed_skills: Collection[str] | None = None,
223
- ) -> ToolAutoResult:
326
+ ) -> AsyncStreamEvents:
224
327
  prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt)
225
328
  if allowed_tools is not None:
226
329
  allowed_tools = {name.casefold() for name in allowed_tools}
@@ -232,8 +335,8 @@ class Agent:
232
335
  else:
233
336
  tools = list(REGISTRY.values())
234
337
  async with asyncio.timeout(self.settings.model_timeout_seconds):
235
- return await tape.run_tools_async(
236
- prompt=prompt, # republic accepts list content parts at runtime
338
+ return await tape.stream_events_async(
339
+ prompt=prompt,
237
340
  system_prompt=self._system_prompt(prompt_text, state=tape.context.state, allowed_skills=allowed_skills),
238
341
  max_tokens=self.settings.max_tokens,
239
342
  tools=model_tools(tools),
@@ -260,15 +363,12 @@ class _ToolAutoOutcome:
260
363
  error: str = ""
261
364
 
262
365
 
263
- def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome:
264
- if output.kind == "text":
265
- return _ToolAutoOutcome(kind="text", text=output.text or "")
266
- if output.kind == "tools" or output.tool_calls or output.tool_results:
366
+ def _resolve_tool_auto_result(final_data: dict[str, Any]) -> _ToolAutoOutcome:
367
+ if (text := final_data.get("text")) is not None:
368
+ return _ToolAutoOutcome(kind="text", text=text)
369
+ if final_data.get("tool_calls") or final_data.get("tool_results"):
267
370
  return _ToolAutoOutcome(kind="continue")
268
- if output.error is None:
269
- return _ToolAutoOutcome(kind="error", error="tool_auto_error: unknown")
270
- error_kind = getattr(output.error.kind, "value", str(output.error.kind))
271
- return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}")
371
+ return _ToolAutoOutcome(kind="error", error="unknown error")
272
372
 
273
373
 
274
374
  def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context: TapeContext) -> LLM:
@@ -318,6 +418,11 @@ def _parse_args(args_tokens: list[str]) -> Args:
318
418
  return Args(positional=positional, kwargs=kwargs)
319
419
 
320
420
 
421
+ def _is_context_length_error(error_msg: str) -> bool:
422
+ """Check whether an error message indicates a context-length / prompt-too-long failure."""
423
+ return bool(_CONTEXT_LENGTH_PATTERNS.search(error_msg))
424
+
425
+
321
426
  def _extract_text_from_parts(parts: list[dict]) -> str:
322
427
  """Extract text content from multimodal content parts."""
323
428
  return "\n".join(p.get("text", "") for p in parts if p.get("type") == "text")