2b-agent 0.2.3__tar.gz → 0.2.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 (54) hide show
  1. {2b_agent-0.2.3 → 2b_agent-0.2.4}/PKG-INFO +9 -1
  2. {2b_agent-0.2.3 → 2b_agent-0.2.4}/README.md +8 -0
  3. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/__init__.py +1 -1
  4. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/orchestrator.py +11 -4
  5. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/providers/anthropic.py +11 -2
  6. 2b_agent-0.2.4/src/two_b/subagents.py +110 -0
  7. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/toolspec.py +27 -0
  8. 2b_agent-0.2.4/tests/test_delegate_wiring.py +32 -0
  9. 2b_agent-0.2.4/tests/test_prompt_cache.py +16 -0
  10. 2b_agent-0.2.4/tests/test_subagents.py +78 -0
  11. {2b_agent-0.2.3 → 2b_agent-0.2.4}/.github/workflows/release.yml +0 -0
  12. {2b_agent-0.2.3 → 2b_agent-0.2.4}/.gitignore +0 -0
  13. {2b_agent-0.2.3 → 2b_agent-0.2.4}/LICENSE +0 -0
  14. {2b_agent-0.2.3 → 2b_agent-0.2.4}/NOTICE +0 -0
  15. {2b_agent-0.2.3 → 2b_agent-0.2.4}/install.sh +0 -0
  16. {2b_agent-0.2.3 → 2b_agent-0.2.4}/pyproject.toml +0 -0
  17. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/app_tui.py +0 -0
  18. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/banner.py +0 -0
  19. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/cli.py +0 -0
  20. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/commands.py +0 -0
  21. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/config.py +0 -0
  22. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/conversation.py +0 -0
  23. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/diagnostics.py +0 -0
  24. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/doctor.py +0 -0
  25. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/lsp.py +0 -0
  26. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/mcp_client.py +0 -0
  27. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/planparse.py +0 -0
  28. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/prompt.py +0 -0
  29. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/providers/__init__.py +0 -0
  30. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/providers/base.py +0 -0
  31. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/providers/google.py +0 -0
  32. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/providers/ollama.py +0 -0
  33. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/providers/openai_compat.py +0 -0
  34. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/rawkey.py +0 -0
  35. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/registry.py +0 -0
  36. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/repomap.py +0 -0
  37. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/session.py +0 -0
  38. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/symbols.py +0 -0
  39. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/theme.py +0 -0
  40. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/tools.py +0 -0
  41. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/tui.py +0 -0
  42. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/uninstall.py +0 -0
  43. {2b_agent-0.2.3 → 2b_agent-0.2.4}/src/two_b/update.py +0 -0
  44. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/spike_ctrl_b.py +0 -0
  45. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_default_model.py +0 -0
  46. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_diagnostics.py +0 -0
  47. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_doctor.py +0 -0
  48. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_edit_file.py +0 -0
  49. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_lsp.py +0 -0
  50. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_mcp_resolver.py +0 -0
  51. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_search_semantics.py +0 -0
  52. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_uninstall.py +0 -0
  53. {2b_agent-0.2.3 → 2b_agent-0.2.4}/tests/test_update.py +0 -0
  54. {2b_agent-0.2.3 → 2b_agent-0.2.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: 2b-agent
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: A local-first coding agent that keeps small local models focused instead of hallucinating.
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -106,6 +106,14 @@ model has to understand.
106
106
  - **Runs things — split by model.** Local models get `run_git` (git only, never a raw shell — no
107
107
  chaining/injection); cloud models get a full `run_command` shell (tests, build, git). Read-only git
108
108
  runs freely; anything that mutates is confirmation-gated and refused in plan mode.
109
+ - **Delegates read-only exploration (cloud).** On the cloud path the model can `delegate` one or
110
+ more investigations to run in parallel, each in its own isolated context, and get back short
111
+ findings — so a big search-and-read never bloats the main conversation. Each sub-agent can only
112
+ `list_files`, `read_file`, and `search_files`; local models keep their frozen five tools
113
+ untouched, and delegation is cloud-only for now.
114
+ - **Cheaper multi-turn cloud sessions.** Anthropic requests mark the system prompt and tool
115
+ definitions as cacheable, so a long conversation pays full price for that stable prefix once
116
+ instead of on every turn.
109
117
  - **MCP tools, curated.** Pull in tools from MCP servers (dart, mempalace, …) — but **per tool**, not
110
118
  wholesale, because flooding a small model with tools is exactly what breaks it. You enable a server
111
119
  and pick which of its tools the model sees (`/mcp`); local models are capped to a few so their
@@ -92,6 +92,14 @@ model has to understand.
92
92
  - **Runs things — split by model.** Local models get `run_git` (git only, never a raw shell — no
93
93
  chaining/injection); cloud models get a full `run_command` shell (tests, build, git). Read-only git
94
94
  runs freely; anything that mutates is confirmation-gated and refused in plan mode.
95
+ - **Delegates read-only exploration (cloud).** On the cloud path the model can `delegate` one or
96
+ more investigations to run in parallel, each in its own isolated context, and get back short
97
+ findings — so a big search-and-read never bloats the main conversation. Each sub-agent can only
98
+ `list_files`, `read_file`, and `search_files`; local models keep their frozen five tools
99
+ untouched, and delegation is cloud-only for now.
100
+ - **Cheaper multi-turn cloud sessions.** Anthropic requests mark the system prompt and tool
101
+ definitions as cacheable, so a long conversation pays full price for that stable prefix once
102
+ instead of on every turn.
95
103
  - **MCP tools, curated.** Pull in tools from MCP servers (dart, mempalace, …) — but **per tool**, not
96
104
  wholesale, because flooding a small model with tools is exactly what breaks it. You enable a server
97
105
  and pick which of its tools the model sees (`/mcp`); local models are capped to a few so their
@@ -5,4 +5,4 @@ host keeps the model's world simple — a small, native tool schema over the
5
5
  provider's own wire format, with all orchestration complexity kept host-side.
6
6
  """
7
7
 
8
- __version__ = "0.2.3"
8
+ __version__ = "0.2.4"
@@ -33,7 +33,7 @@ from . import diagnostics, mcp_client, planparse, registry, tools
33
33
  from .conversation import Conversation, Message, Role, ToolResult
34
34
  from .providers.base import ProviderError
35
35
  from .session import PendingConfirmation, Session, Task, TaskState
36
- from .toolspec import TOOL_SPECS, specs_for
36
+ from .toolspec import TOOL_SPECS, specs_for, DELEGATE_SPEC
37
37
 
38
38
  MAX_TURNS = 40 # generous budget for real multi-step tasks
39
39
  DEFAULT_MODEL = "qwen3.5:9b"
@@ -158,11 +158,13 @@ def _project_context() -> str:
158
158
 
159
159
  def _active_specs(is_local: bool):
160
160
  """Base file tools + the model's exec tool + curated MCP tools. Local models
161
- get a small MCP cap so a big enabled set can't flood their tool list."""
161
+ get a small MCP cap so a big enabled set can't flood their tool list.
162
+ delegate (fan-out to sub-agents) is exposed to cloud models only."""
162
163
  mcp = mcp_client.manager.tool_specs()
163
164
  if is_local:
164
165
  mcp = mcp[:MCP_LOCAL_CAP]
165
- return specs_for(is_local) + mcp
166
+ base = specs_for(is_local) + mcp
167
+ return base if is_local else base + (DELEGATE_SPEC,)
166
168
 
167
169
 
168
170
  def context_budget(provider, model: str) -> int:
@@ -512,7 +514,12 @@ def run_task(session: Session, task: Task, on_event: Callable[[AgentEvent], None
512
514
  task.status_line = _STATUS.get(tc.name, "Working")
513
515
  shown = {k: (v if k != "content" else f"<{len(v)} chars>") for k, v in tc.arguments.items()}
514
516
  on_event(AgentEvent(EventType.TOOL_CALL_START, task.id, {"name": tc.name, "shown": shown}))
515
- result = _dispatch_tool(session, task, tc.name, tc.arguments, read_cap)
517
+ if tc.name == "delegate" and not is_local:
518
+ from . import subagents
519
+ result = subagents.delegate(tc.arguments.get("tasks", []), provider, model,
520
+ read_cap=read_cap, on_event=on_event, cancel=task.cancel_flag)
521
+ else:
522
+ result = _dispatch_tool(session, task, tc.name, tc.arguments, read_cap)
516
523
  on_event(AgentEvent(EventType.TOOL_CALL_RESULT, task.id, {"name": tc.name, "result": result}))
517
524
  results.append(ToolResult(tool_call_id=tc.id, content=result))
518
525
  conv.append(Message.results(results))
@@ -55,11 +55,20 @@ class AnthropicProvider:
55
55
  return out
56
56
 
57
57
  def send(self, conversation: Conversation, model: str, tools: tuple[ToolSpec, ...]) -> ProviderResponse:
58
+ # Prompt caching (GA — no beta header needed): mark the stable prefix
59
+ # (system prompt, last tool definition) with cache_control so repeated
60
+ # requests reuse Anthropic's cache instead of paying full price every
61
+ # turn. OpenAI-compatible providers cache automatically server-side —
62
+ # no payload change needed there.
63
+ tools_json = to_anthropic(tools)
64
+ if tools_json:
65
+ tools_json[-1] = {**tools_json[-1], "cache_control": {"type": "ephemeral"}}
58
66
  payload = {
59
67
  "model": model,
60
68
  "max_tokens": 4096,
61
- "system": conversation.system_prompt,
62
- "tools": to_anthropic(tools),
69
+ "system": [{"type": "text", "text": conversation.system_prompt,
70
+ "cache_control": {"type": "ephemeral"}}],
71
+ "tools": tools_json,
63
72
  "messages": self._messages(conversation),
64
73
  }
65
74
  raw = post_json(API_URL, payload, headers=self._headers(), provider=self.name)
@@ -0,0 +1,110 @@
1
+ """Cloud-path subagents: parallel, isolated read-only explorers behind the `delegate`
2
+ tool. Each runs in its own Conversation with only the read tools and returns a distilled
3
+ findings report — heavy file reading happens here and never enters the parent context."""
4
+ from __future__ import annotations
5
+ import concurrent.futures
6
+ import threading
7
+ from . import tools
8
+ from .conversation import Conversation, Message, ToolResult
9
+
10
+ EXPLORER_PROMPT = (
11
+ "You are a read-only exploration agent. Investigate the goal using list_files, "
12
+ "read_file, and search_files, then STOP and reply with a concise findings report: "
13
+ "what you found, the concrete file:line references, and anything the caller needs. "
14
+ "You cannot edit, write, or run commands. Keep the report short — it is folded back "
15
+ "into another agent's context, so summarize; do not paste large file bodies."
16
+ )
17
+
18
+ def _read_dispatch(name: str, args: dict, read_cap: int | None) -> str:
19
+ if name == "list_files":
20
+ return tools.do_list_files(args.get("path", "."), max_chars=read_cap)
21
+ if name == "read_file":
22
+ return tools.do_read_file(args["path"], max_chars=read_cap)
23
+ if name == "search_files":
24
+ return tools.do_search_files(args["query"], args.get("path", "."))
25
+ return f"error: '{name}' is not available to an explorer (read-only)"
26
+
27
+
28
+ def run_explorer(goal, provider, model, read_cap=None, max_turns=8, cancel=None):
29
+ conv = Conversation(system_prompt=EXPLORER_PROMPT)
30
+ conv.append(Message.user(goal))
31
+ specs = tuple(s for s in _explorer_specs()) # read-only tool specs
32
+ for _ in range(max_turns):
33
+ if cancel is not None and cancel.is_set():
34
+ return "explorer cancelled"
35
+ resp = provider.stream(conv, model, specs, lambda _c: None)
36
+ msg = resp.message
37
+ conv.append(msg)
38
+ if not msg.tool_calls:
39
+ return (msg.text or "").strip() or "(explorer produced no findings)"
40
+ results = [ToolResult(tool_call_id=tc.id,
41
+ content=_read_dispatch(tc.name, tc.arguments, read_cap))
42
+ for tc in msg.tool_calls]
43
+ conv.append(Message.results(results))
44
+ return "(explorer hit its turn limit without a final report)"
45
+
46
+
47
+ def _explorer_specs():
48
+ from .toolspec import TOOL_SPECS
49
+ keep = {"list_files", "read_file", "search_files"}
50
+ return [s for s in TOOL_SPECS if s.name in keep]
51
+
52
+
53
+ class _AnyEvent:
54
+ """Read-only OR of several threading.Events: is_set() is True if any is set.
55
+ Lets an explorer honor both the parent task's cancel (esc) and delegate's own
56
+ batch-timeout signal, while delegate only ever sets its OWN event."""
57
+ def __init__(self, *events):
58
+ self._events = [e for e in events if e is not None]
59
+ def is_set(self) -> bool:
60
+ return any(e.is_set() for e in self._events)
61
+
62
+
63
+ MAX_PARALLEL = 4
64
+ DELEGATE_TIMEOUT = 180 # seconds, wall-clock budget for the whole batch
65
+ _MAX_SECTION = 4000
66
+
67
+
68
+ def delegate(tasks, provider, model, read_cap=None, on_event=None, cancel=None) -> str:
69
+ tasks = [t for t in (tasks or []) if isinstance(t, dict) and t.get("goal")]
70
+ if not tasks:
71
+ return "error: delegate needs at least one {role, goal} task"
72
+
73
+ sub_cancel = threading.Event()
74
+ combined = _AnyEvent(cancel, sub_cancel)
75
+
76
+ def _one(t):
77
+ role, goal = (t.get("role") or "explore"), t["goal"]
78
+ if role == "work":
79
+ return role, goal, "(worker delegation is not enabled yet — Phase 2)"
80
+ try:
81
+ return role, goal, run_explorer(goal, provider, model, read_cap=read_cap, cancel=combined)
82
+ except Exception as e: # a subagent failing must not kill the batch
83
+ return role, goal, f"(explorer error: {str(e)[:200]})"
84
+
85
+ # Not a `with` block on purpose: ThreadPoolExecutor.__exit__ calls
86
+ # shutdown(wait=True), which would block on any straggler exactly like the
87
+ # timeout below is meant to avoid. We call shutdown() exactly once, with
88
+ # wait=False, so this function returns as soon as the batch timeout hits.
89
+ results: list[tuple[str, str, str] | None] = [None] * len(tasks)
90
+ ex = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_PARALLEL)
91
+ futures = {ex.submit(_one, t): i for i, t in enumerate(tasks)}
92
+ try:
93
+ for fut in concurrent.futures.as_completed(futures, timeout=DELEGATE_TIMEOUT):
94
+ results[futures[fut]] = fut.result()
95
+ except concurrent.futures.TimeoutError:
96
+ sub_cancel.set()
97
+ finally:
98
+ ex.shutdown(wait=False, cancel_futures=True)
99
+
100
+ lines = [f"## delegate results ({len(results)} task(s))"]
101
+ for i, (t, r) in enumerate(zip(tasks, results), 1):
102
+ if r is None:
103
+ role, goal = (t.get("role") or "explore"), t["goal"]
104
+ out = "(timed out)"
105
+ else:
106
+ role, goal, out = r
107
+ if len(out) > _MAX_SECTION:
108
+ out = out[:_MAX_SECTION] + " …[truncated]"
109
+ lines.append(f"\n### [{i}] {role}: {goal}\n{out}")
110
+ return "\n".join(lines)
@@ -123,3 +123,30 @@ def to_gemini(specs: tuple[ToolSpec, ...] = TOOL_SPECS) -> list[dict]:
123
123
 
124
124
  # Guarantee the local-Ollama payload is byte-identical to the proven constant.
125
125
  assert to_openai() == tools.TOOLS, "toolspec.to_openai() drifted from tools.TOOLS"
126
+
127
+
128
+ DELEGATE_SPEC = ToolSpec(
129
+ "delegate",
130
+ "Run one or more independent sub-tasks in parallel, each in its own isolated context. "
131
+ "role 'explore' investigates read-only and returns a concise findings report — use it to "
132
+ "locate code or understand a flow without reading everything into this conversation. "
133
+ "role 'work' is reserved. Returns a digest of each task's result.",
134
+ raw_schema={
135
+ "type": "object",
136
+ "properties": {
137
+ "tasks": {
138
+ "type": "array",
139
+ "items": {
140
+ "type": "object",
141
+ "properties": {
142
+ "role": {"type": "string", "enum": ["explore", "work"]},
143
+ "goal": {"type": "string", "description": "What to find/understand (explore) — a clear, self-contained instruction."},
144
+ },
145
+ "required": ["goal"],
146
+ },
147
+ "description": "Independent sub-tasks run in parallel.",
148
+ }
149
+ },
150
+ "required": ["tasks"],
151
+ },
152
+ )
@@ -0,0 +1,32 @@
1
+ import os
2
+ import sys
3
+ import unittest
4
+
5
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
6
+
7
+ from two_b import orchestrator
8
+
9
+
10
+ class Exposure(unittest.TestCase):
11
+ def test_local_has_no_delegate(self):
12
+ names = [s.name for s in orchestrator._active_specs(is_local=True)]
13
+ self.assertNotIn("delegate", names)
14
+ self.assertEqual(
15
+ names[:6],
16
+ ["list_files", "read_file", "search_files", "edit_file", "write_file", "run_git"],
17
+ )
18
+
19
+ def test_cloud_has_delegate(self):
20
+ names = [s.name for s in orchestrator._active_specs(is_local=False)]
21
+ self.assertIn("delegate", names)
22
+
23
+ def test_frozen_schema_still_holds(self):
24
+ # two_b.tools import triggers the toolspec.py assert (to_openai() == tools.TOOLS)
25
+ import two_b.tools as tools
26
+ from two_b.toolspec import to_openai, TOOL_SPECS
27
+ self.assertEqual(to_openai(), tools.TOOLS)
28
+ self.assertNotIn("delegate", [s.name for s in TOOL_SPECS])
29
+
30
+
31
+ if __name__ == "__main__":
32
+ unittest.main()
@@ -0,0 +1,16 @@
1
+ import os, sys, unittest
2
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
3
+ from two_b.providers import anthropic as A
4
+ from two_b.conversation import Conversation
5
+ from two_b.toolspec import TOOL_SPECS
6
+
7
+
8
+ class Cache(unittest.TestCase):
9
+ def test_system_and_tools_cached(self):
10
+ captured = {}
11
+ A.post_json = lambda url, payload, **k: captured.setdefault("p", payload) or {"content": [{"type": "text", "text": "ok"}]}
12
+ os.environ["ANTHROPIC_API_KEY"] = "x"
13
+ A.AnthropicProvider().send(Conversation(system_prompt="SYS"), "claude-sonnet-5", tuple(TOOL_SPECS))
14
+ p = captured["p"]
15
+ self.assertEqual(p["system"][-1]["cache_control"], {"type": "ephemeral"})
16
+ self.assertEqual(p["tools"][-1]["cache_control"], {"type": "ephemeral"})
@@ -0,0 +1,78 @@
1
+ import os, sys, tempfile, unittest
2
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
3
+ from two_b import subagents
4
+
5
+ class ReadDispatch(unittest.TestCase):
6
+ def test_write_tools_refused(self):
7
+ self.assertIn("not available", subagents._read_dispatch("edit_file", {"path":"x"}, None))
8
+ self.assertIn("not available", subagents._read_dispatch("run_command", {"command":"ls"}, None))
9
+ def test_read_tools_allowed(self):
10
+ d = tempfile.mkdtemp()
11
+ with open(os.path.join(d, "a.py"), "w") as f:
12
+ f.write("x=1\n")
13
+ out = subagents._read_dispatch("list_files", {"path": d}, None)
14
+ self.assertIn("a.py", out)
15
+
16
+
17
+ class RunExplorer(unittest.TestCase):
18
+ def test_loops_then_returns_final_text(self):
19
+ from two_b.conversation import Message, ToolCall
20
+ calls = iter([
21
+ Message.assistant(tool_calls=[ToolCall.new("search_files", {"query":"Widget"})]),
22
+ Message.assistant(text="Widget is defined in a.py:1"),
23
+ ])
24
+ class FakeProvider:
25
+ name = "anthropic"
26
+ def stream(self, conv, model, tools_, on_text):
27
+ from two_b.providers.base import ProviderResponse
28
+ return ProviderResponse(message=next(calls), raw={})
29
+ out = subagents.run_explorer("find Widget", FakeProvider(), "m")
30
+ self.assertEqual(out, "Widget is defined in a.py:1")
31
+
32
+
33
+ class Delegate(unittest.TestCase):
34
+ def setUp(self):
35
+ self._orig_run_explorer = subagents.run_explorer
36
+
37
+ def tearDown(self):
38
+ subagents.run_explorer = self._orig_run_explorer
39
+
40
+ def test_digest_has_one_section_per_task(self):
41
+ subagents.run_explorer = lambda goal, *a, **k: f"found: {goal}" # stub
42
+ out = subagents.delegate(
43
+ [{"role":"explore","goal":"A"}, {"role":"explore","goal":"B"}],
44
+ provider=None, model="m")
45
+ self.assertIn("A", out); self.assertIn("B", out)
46
+ self.assertIn("found: A", out); self.assertIn("found: B", out)
47
+
48
+ def test_work_role_stubbed(self):
49
+ out = subagents.delegate([{"role":"work","goal":"edit x"}], provider=None, model="m")
50
+ self.assertIn("not enabled yet", out)
51
+
52
+ def test_batch_failure_isolation(self):
53
+ def flaky(goal, *a, **k):
54
+ if goal == "bad":
55
+ raise RuntimeError("boom")
56
+ return f"found: {goal}"
57
+ subagents.run_explorer = flaky
58
+ out = subagents.delegate(
59
+ [{"role": "explore", "goal": "bad"}, {"role": "explore", "goal": "good"}],
60
+ provider=None, model="m")
61
+ self.assertIn("### [1] explore: bad", out)
62
+ self.assertIn("explorer error", out)
63
+ self.assertIn("### [2] explore: good", out)
64
+ self.assertIn("found: good", out)
65
+
66
+ def test_batch_timeout_does_not_touch_parent_cancel(self):
67
+ import threading, time
68
+ from two_b import subagents
69
+ parent = threading.Event()
70
+ subagents.run_explorer = lambda goal, *a, **k: (time.sleep(0.5) or "late") # slower than the tiny budget
71
+ orig = subagents.DELEGATE_TIMEOUT
72
+ subagents.DELEGATE_TIMEOUT = 0.05
73
+ try:
74
+ out = subagents.delegate([{"role":"explore","goal":"slow"}], provider=None, model="m", cancel=parent)
75
+ finally:
76
+ subagents.DELEGATE_TIMEOUT = orig
77
+ self.assertFalse(parent.is_set()) # parent task must NOT be cancelled
78
+ self.assertIn("(timed out)", out)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes