Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes/94.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**Helpers working side by side list only their own files.** When several helpers ran at once, each one could report
files the others had saved. Each now lists just the files it wrote itself.
3 changes: 2 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -2514,7 +2514,8 @@ Every new tool declares a `Risk`; approvals behave as in section 1.
### Subagents
- Tools (plugin `subagents`):
- `delegate_task(goal, context="", tools: list[str] | None = None, background: bool = False)` (risk `write`, internal).
Foreground: waits and returns `{subagent_id, status, summary, files_created}`. Background: returns
Foreground: waits and returns `{subagent_id, status, summary, files_created}`; `files_created` lists the files its
own tool calls saved in the files folder, relative to that folder (#94). Background: returns
`{subagent_id, status: "running"}` at once; when it finishes, a summary is added to the chat as an assistant message,
`subagent.updated` fires and a notification is created.
- `delegate_tasks(tasks: list[{goal, context?}])` runs up to `subagents.max_concurrent` in parallel and returns every summary.
Expand Down
47 changes: 21 additions & 26 deletions sentient/agent/subagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import logging
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any

from sentient import paths
Expand All @@ -40,7 +41,6 @@
log = logging.getLogger(__name__)

MAX_EVENTS = 200
MAX_FILES_SCANNED = 5000
PLUGIN_ID = "subagents"
UpdateFn = Callable[[str, str], Any] # (subagent_id, message)

Expand Down Expand Up @@ -92,21 +92,19 @@ def _row(r: Any, *, events: bool = True) -> dict:
return out


def _snapshot_files() -> dict[str, float]:
root = paths.files_dir()
out: dict[str, float] = {}
if not root.exists():
return out
for i, p in enumerate(root.rglob("*")):
if i >= MAX_FILES_SCANNED:
break
if p.is_file():
rel = p.relative_to(root).as_posix()
if rel.startswith("outputs/tool-"): # cut tool results are bookkeeping, not work products
continue
with contextlib.suppress(OSError):
out[rel] = p.stat().st_mtime
return out
def _files_from(result: Any) -> list[str]:
"""The files one of the subagent's own tool calls saved, relative to the files folder: any result whose ``path``
is inside the files folder, plus a script run's ``files_created``. Read from its results rather than by
comparing the folder before and after, so helpers running at once never report each other's files (#94)."""
if not isinstance(result, dict):
return []
names: list[str] = []
path = result.get("path")
if isinstance(path, str) and Path(path).is_absolute():
with contextlib.suppress(OSError, ValueError):
names.append(Path(path).resolve().relative_to(paths.files_dir().resolve()).as_posix())
names += [n for n in result.get("files_created") or [] if isinstance(n, str)]
return names


def _short(value: Any, limit: int = 1000) -> str:
Expand Down Expand Up @@ -424,7 +422,7 @@ async def _run(
events: list[dict] = []
result = LoopResult()
status, error = "completed", None
before: dict[str, float] = {}
files: list[str] = []

def note(message: dict, text: str | None = None) -> None:
events.append({"timestamp": now_iso(), "message": message})
Expand All @@ -437,7 +435,6 @@ def note(message: dict, text: str | None = None) -> None:

try:
async with self._slot():
before = await asyncio.to_thread(_snapshot_files)
note({"type": "info", "content": f"Started: {goal[:200]}"}, "Started")
incognito = await self.app.store.is_incognito(session_id) # kept if the chat is deleted (#154)
private = incognito and not self.app.config.chat.incognito_uses_memory
Expand Down Expand Up @@ -471,6 +468,8 @@ def note(message: dict, text: str | None = None) -> None:
f"Using {event.name}",
)
elif isinstance(event, ToolResultEvent):
if not event.is_error:
files.extend(_files_from(event.result))
note(
{"type": "tool_result", "tool_name": event.name, "result": _short(event.result),
"is_error": event.is_error},
Expand All @@ -487,12 +486,12 @@ def note(message: dict, text: str | None = None) -> None:
status, error = "error", f"Stopped after {cfg.timeout_minutes} minutes without finishing."
except asyncio.CancelledError:
status, error = "cancelled", None
await asyncio.shield(self._finish(sub_id, goal, session_id, background, status, error, result, events, before))
await asyncio.shield(self._finish(sub_id, goal, session_id, background, status, error, result, events, files))
raise
except Exception as exc:
log.exception("subagent %s failed", sub_id)
status, error = "error", f"{type(exc).__name__}: {exc}"
await self._finish(sub_id, goal, session_id, background, status, error, result, events, before)
await self._finish(sub_id, goal, session_id, background, status, error, result, events, files)

async def _finish(
self,
Expand All @@ -504,16 +503,12 @@ async def _finish(
error: str | None,
result: LoopResult,
events: list[dict],
before: dict[str, float],
files: list[str],
) -> None:
summary = (result.text or "").strip()
if result.hit_step_limit and summary.startswith("I reached the step limit"):
summary = ""
try:
after = await asyncio.to_thread(_snapshot_files) if before is not None else {}
except Exception:
after = {}
files = sorted(n for n, m in after.items() if before and (n not in before or before[n] != m)) if before else []
files = sorted(set(files))
if status == "completed":
events.append({"timestamp": now_iso(), "message": {"type": "final_answer", "content": summary}})
elif status == "error":
Expand Down
85 changes: 85 additions & 0 deletions tests/test_subagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from fastapi.testclient import TestClient

from sentient import paths
from sentient.app import SentientApp
from sentient.gateway.app import create_app
from sentient.llm.events import ToolProgress, ToolResultEvent
Expand Down Expand Up @@ -259,3 +260,87 @@ async def test_subagent_stops_on_repeated_calls_and_on_its_token_limit(config, i
assert len(llm.seen) == 2
finally:
await s.stop()


async def test_parallel_subagents_each_report_only_their_own_files(config, isolated_home):
"""Two subagents running at once each list the files they wrote, not the other's (#94)."""
arrived: list[str] = []
both_wrote = asyncio.Event()

@tool("rendezvous", risk=Risk.read)
async def rendezvous(ctx: ToolContext) -> str:
"""Wait for the other helper."""
arrived.append(ctx.extra["subagent_id"])
if len(arrived) == 2:
both_wrote.set()
await asyncio.wait_for(both_wrote.wait(), 30)
return "ok"

class PerGoal(FakeProvider):
"""Each helper gets its own script, whatever order they call the model in."""

def __init__(self, scripts: dict[str, list]):
super().__init__()
self.scripts = scripts

async def stream(self, role, messages, tools=None, *, model=None):
goal = next(g for g in self.scripts if g in messages[1]["content"])
self.replies = [self.scripts[goal].pop(0)]
async for chunk in super().stream(role, messages, tools, model=model):
yield chunk

llm = PerGoal({
"alpha": [[tool_call("file_write", name="alpha.txt", content="A")], [tool_call("rendezvous")], "wrote alpha"],
"beta": [[tool_call("file_write", name="notes/beta.md", content="B")], [tool_call("rendezvous")], "wrote beta"],
})
s = await start(config, isolated_home, llm, "files")
try:
(paths.files_dir() / "older.txt").write_text("already here", encoding="utf-8")
s.registry.register(make_plugin("meeting", rendezvous))
res = await s.subagents.delegate_many([
{"goal": "alpha", "tools": ["file_write", "rendezvous"]},
{"goal": "beta", "tools": ["file_write", "rendezvous"]},
])
assert [r["status"] for r in res] == ["completed", "completed"]
assert res[0]["files_created"] == ["alpha.txt"]
assert res[1]["files_created"] == ["notes/beta.md"]
stored = await s.subagents.get(res[1]["subagent_id"], events=False)
assert stored["files_created"] == ["notes/beta.md"]
finally:
await s.stop()


async def test_files_come_from_any_tool_saving_into_the_files_folder(config, isolated_home):
"""Any tool whose result ``path`` is inside the files folder counts; ``saved`` alone or a path elsewhere,
like skill_save's, does not (#94)."""

@tool("chart_like", risk=Risk.read)
async def chart_like(ctx: ToolContext) -> dict:
"""Save a chart."""
target = paths.files_dir() / "charts" / "steps.png"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"png")
return {"saved": "charts/steps.png", "path": str(target)}

@tool("save_skill_like", risk=Risk.read)
async def save_skill_like(ctx: ToolContext) -> dict:
"""Save something that is not a file."""
return {"saved": str(isolated_home / "skills" / "thing" / "SKILL.md"), "name": "thing",
"path": str(isolated_home / "skills" / "thing" / "SKILL.md")}

@tool("saved_only", risk=Risk.read)
async def saved_only(ctx: ToolContext) -> dict:
"""Say saved without a path."""
return {"saved": "ghost.txt"}

llm = FakeProvider(replies=[
[tool_call("chart_like")], [tool_call("save_skill_like")], [tool_call("saved_only")], "done",
])
s = await start(config, isolated_home, llm, "anytool")
try:
s.registry.register(make_plugin("mixed", chart_like, save_skill_like, saved_only))
res = await s.subagents.delegate("save things", tools=["chart_like", "save_skill_like", "saved_only"])
assert res["status"] == "completed", res
assert res["files_created"] == ["charts/steps.png"]
finally:
await s.stop()
Loading