Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f867e90
docs: add Phase 0 CC architecture extraction + core agent refactor
nmhjklnm Apr 1, 2026
06d4277
feat(state): add three-layer state models
nmhjklnm Apr 1, 2026
7ee412e
feat(cleanup): add CleanupRegistry with priority ordering
nmhjklnm Apr 1, 2026
87931a9
feat(registry): add context_schema to ToolEntry
nmhjklnm Apr 1, 2026
4e2e25f
feat(loop): implement QueryLoop replacing create_agent
nmhjklnm Apr 1, 2026
b0b74a4
feat(fork): add context fork for sub-agents
nmhjklnm Apr 1, 2026
e27aeb8
refactor(agent): replace create_agent with QueryLoop
nmhjklnm Apr 1, 2026
3b962d4
feat(agent-service): use context fork for sub-agent spawn
nmhjklnm Apr 1, 2026
d289d86
fix(compactor): align with CC L4b Legacy Compact design
nmhjklnm Apr 1, 2026
914cd3d
test: add unit tests for state/cleanup/fork/loop
nmhjklnm Apr 1, 2026
c0d5362
test: add integration test for LeonAgent astream
nmhjklnm Apr 1, 2026
eeafaf3
refactor: align tool system with Claude Code design patterns
nmhjklnm Apr 2, 2026
5c001d7
fix(search): align Grep/Glob with CC ripgrep behavior
nmhjklnm Apr 2, 2026
fe19e37
feat(lsp): add LSP tool via multilspy (5 operations)
nmhjklnm Apr 2, 2026
9a93068
refactor(lsp): promote multilspy to core dep + CC alignment fixes
nmhjklnm Apr 2, 2026
c33b35a
feat(lsp): add goToImplementation and call hierarchy operations
nmhjklnm Apr 2, 2026
a6c77da
fix(lsp): correct symbol formatters and handle multilspy AssertionError
nmhjklnm Apr 2, 2026
ed27985
feat(lsp): add _PyrightSession for Python call hierarchy via pyright-…
nmhjklnm Apr 2, 2026
ddca1f9
fix: remove dead code, add lsp package to pyproject, update plan doc
nmhjklnm Apr 2, 2026
23725b6
refactor(lsp): promote language servers to process-level singletons
nmhjklnm Apr 2, 2026
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
4 changes: 4 additions & 0 deletions backend/web/core/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,7 @@ async def _wechat_deliver(conn, msg):
agent.close()
except Exception as e:
print(f"[web] Agent cleanup error: {e}")

# Cleanup: stop LSP language servers
from core.tools.lsp.service import lsp_pool
await lsp_pool.close_all()
1 change: 1 addition & 0 deletions config/defaults/tool_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class ToolDef(BaseModel):
ToolDef(name="load_skill", desc="加载 Skill", group=ToolGroup.SKILLS),
# system
ToolDef(name="tool_search", desc="搜索可用工具", group=ToolGroup.SYSTEM),
ToolDef(name="LSP", desc="Language Server Protocol 操作", group=ToolGroup.SYSTEM, mode=ToolMode.DEFERRED, default=False),
# taskboard — all off by default; enable on dedicated scheduler members
ToolDef(name="ListBoardTasks", desc="列出任务板上的任务", group=ToolGroup.TASKBOARD, default=False),
ToolDef(name="ClaimTask", desc="认领一个任务板任务", group=ToolGroup.TASKBOARD, default=False),
Expand Down
325 changes: 167 additions & 158 deletions core/agents/communication/chat_tool_service.py

Large diffs are not rendered by default.

19 changes: 11 additions & 8 deletions core/agents/communication/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import functools
import logging
from typing import Any

Expand Down Expand Up @@ -41,18 +42,20 @@ def _deliver(
loop,
)

def _on_done(f):
exc = f.exception()
if exc:
logger.error("[delivery] async delivery failed for %s: %s", entity.id, exc, exc_info=exc)
else:
logger.info("[delivery] async delivery completed for %s", entity.id)

future.add_done_callback(_on_done)
future.add_done_callback(functools.partial(_log_delivery_result, entity.id))

return _deliver


def _log_delivery_result(entity_id: str, f: Any) -> None:
"""Done-callback for async delivery futures."""
exc = f.exception()
if exc:
logger.error("[delivery] async delivery failed for %s: %s", entity_id, exc, exc_info=exc)
else:
logger.info("[delivery] async delivery completed for %s", entity_id)


async def _async_deliver(
app: Any,
entity: EntityRow,
Expand Down
156 changes: 144 additions & 12 deletions core/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,85 @@

logger = logging.getLogger(__name__)

# ── Sub-agent tool filtering (CC alignment) ──────────────────────────────────
# Tools that sub-agents must never access (prevents controlling parent).
AGENT_DISALLOWED: set[str] = {"TaskOutput", "TaskStop", "Agent"}

# Per-type allowed tool sets. Tools not in the set are blocked.
EXPLORE_ALLOWED: set[str] = {"Read", "Grep", "Glob", "list_dir", "WebSearch", "WebFetch", "tool_search"}
PLAN_ALLOWED: set[str] = EXPLORE_ALLOWED # plan agents are also read-only
BASH_ALLOWED: set[str] = {"Bash", "Read", "Grep", "Glob", "list_dir", "tool_search"}


def _get_tool_filters(subagent_type: str) -> tuple[set[str], set[str] | None]:
"""Return (extra_blocked_tools, allowed_tools) for a sub-agent type.

For explore/plan/bash: use allowed_tools whitelist (ToolRegistry skips unmatched).
For general: only block AGENT_DISALLOWED, no whitelist.
"""
agent_type = subagent_type.lower()
allowed_map: dict[str, set[str]] = {
"explore": EXPLORE_ALLOWED,
"plan": PLAN_ALLOWED,
"bash": BASH_ALLOWED,
}

if agent_type in allowed_map:
return AGENT_DISALLOWED, allowed_map[agent_type]

# general: only block parent-controlling tools, no whitelist
return AGENT_DISALLOWED, None


def _filter_fork_messages(messages: list) -> list:
"""Filter parent messages for forkContext sub-agent spawning.

Equivalent to CC's yF0: removes assistant messages whose tool_use blocks
have no matching tool_result in a subsequent user message (orphan tool_use).
Orphan tool_use blocks cause Anthropic API validation errors.
"""
# Collect all tool_use_ids that have a corresponding tool_result
answered: set[str] = set()
for msg in messages:
# ToolMessage or user message with tool_result content
tool_call_id = getattr(msg, "tool_call_id", None)
if tool_call_id:
answered.add(tool_call_id)
content = getattr(msg, "content", None)
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
tid = block.get("tool_use_id") or block.get("tool_call_id")
if tid:
answered.add(tid)

result = []
for msg in messages:
content = getattr(msg, "content", None)
if isinstance(content, list):
tool_uses = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
if tool_uses and any(b.get("id") not in answered for b in tool_uses):
continue # skip assistant message with unanswered tool_use
result.append(msg)
return result


AGENT_SCHEMA = {
"name": "Agent",
"description": (
"Launch a new agent to handle complex tasks autonomously. "
"Use subagent_type to select a specialized agent, or omit for default. "
"Agents run independently with their own tool stack."
"Launch a sub-agent for independent task execution. "
"Types: explore (read-only codebase search), plan (architecture design, read-only), "
"bash (shell commands only), general (full tool access). "
"Use for: multi-step tasks, parallel work, tasks needing isolation. "
"Do NOT use for simple file reads or single grep searches — use the tools directly."
),
"parameters": {
"type": "object",
"properties": {
"subagent_type": {
"type": "string",
"description": "Type of agent to spawn (e.g. 'Explore', 'Coder'). Omit for general-purpose.",
"enum": ["explore", "plan", "general", "bash"],
"description": "Type of agent to spawn. Omit for general-purpose.",
},
"prompt": {
"type": "string",
Expand All @@ -60,14 +125,24 @@
"type": "integer",
"description": "Maximum turns the agent can take",
},
"fork_context": {
"type": "boolean",
"default": False,
"description": (
"Inherit parent conversation history as read-only context. "
"Use when the sub-agent needs background from the parent's work. "
"Adds a ### ENTERING SUB-AGENT ROUTINE ### marker so the sub-agent "
"knows which messages are context vs its actual task."
),
},
},
"required": ["prompt"],
},
}

TASK_OUTPUT_SCHEMA = {
"name": "TaskOutput",
"description": "Get the output of a background agent task by its task_id.",
"description": "Get output of a background task (agent or bash). Blocks until task completes by default. Returns full text output or error.",
"parameters": {
"type": "object",
"properties": {
Expand All @@ -82,7 +157,7 @@

TASK_STOP_SCHEMA = {
"name": "TaskStop",
"description": "Stop a running background agent task.",
"description": "Cancel a running background task. Sends cancellation signal; task may take a moment to stop.",
"parameters": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -185,6 +260,7 @@ def __init__(
schema=AGENT_SCHEMA,
handler=self._handle_agent,
source="AgentService",
search_hint="launch sub-agent spawn parallel task independent",
)
)
tool_registry.register(
Expand All @@ -194,6 +270,9 @@ def __init__(
schema=TASK_OUTPUT_SCHEMA,
handler=self._handle_task_output,
source="AgentService",
search_hint="get background task output result poll",
is_read_only=True,
is_concurrency_safe=True,
)
)
tool_registry.register(
Expand All @@ -203,6 +282,7 @@ def __init__(
schema=TASK_STOP_SCHEMA,
handler=self._handle_task_stop,
source="AgentService",
search_hint="stop cancel background task agent",
)
)

Expand All @@ -214,6 +294,7 @@ async def _handle_agent(
description: str | None = None,
run_in_background: bool = False,
max_turns: int | None = None,
fork_context: bool = False,
) -> str:
"""Spawn an independent LeonAgent and run it with the given prompt."""
from sandbox.thread_context import get_current_thread_id
Expand Down Expand Up @@ -245,6 +326,7 @@ async def _handle_agent(
max_turns,
description=description or "",
run_in_background=run_in_background,
fork_context=fork_context,
)
)
if run_in_background:
Expand Down Expand Up @@ -281,6 +363,7 @@ async def _run_agent(
max_turns: int | None,
description: str = "",
run_in_background: bool = False,
fork_context: bool = False,
) -> str:
"""Create and run an independent LeonAgent, collect its text output."""
# Isolate this sub-agent from the parent's LangChain callback chain.
Expand Down Expand Up @@ -316,11 +399,44 @@ async def _run_agent(

agent = None
try:
agent = create_leon_agent(
model_name=self._model_name,
workspace_root=self._workspace_root,
verbose=False,
)
# Sub-agent context trimming: each spawn creates a fresh LeonAgent
# with its own _build_system_prompt(). No CLAUDE.md content or
# gitStatus is injected into the prompt pipeline (core/runtime/prompts
# has no such injection). Therefore explore/plan/bash sub-agents
# already run lightweight — no extra trimming is needed.
#
# Try to use context fork from parent agent's BootstrapConfig.
# Falls back to create_leon_agent when bootstrap is not available.
# Compute tool filtering for this sub-agent type
extra_blocked, allowed = _get_tool_filters(subagent_type)

try:
from core.runtime.fork import fork_context

# Parent bootstrap is stored on the ToolUseContext or agent instance.
# AgentService stores workspace_root and model_name directly; use those
# to check if a richer bootstrap is available via a shared reference.
# _parent_bootstrap is injected by LeonAgent when building AgentService.
parent_bootstrap = getattr(self, "_parent_bootstrap", None)
if parent_bootstrap is not None:
child_bootstrap = fork_context(parent_bootstrap)
agent = create_leon_agent(
model_name=child_bootstrap.model_name,
workspace_root=child_bootstrap.workspace_root,
extra_blocked_tools=extra_blocked,
allowed_tools=allowed,
verbose=False,
)
else:
raise AttributeError("no parent bootstrap")
except (AttributeError, ImportError):
agent = create_leon_agent(
model_name=self._model_name,
workspace_root=self._workspace_root,
extra_blocked_tools=extra_blocked,
allowed_tools=allowed,
verbose=False,
)
# In async context LeonAgent defers checkpointer init; call ainit() to
# ensure state is persisted (and loadable via GET /api/threads/{thread_id}).
await agent.ainit()
Expand Down Expand Up @@ -354,8 +470,24 @@ async def _run_agent(
config = {"configurable": {"thread_id": thread_id}}
output_parts: list[str] = []

# Build initial input — with or without forked parent context
if fork_context:
from sandbox.thread_context import get_current_messages
parent_msgs = get_current_messages()
_FORK_MARKER = (
"\n\n### ENTERING SUB-AGENT ROUTINE ###\n"
"Messages above are from the parent thread (read-only context).\n"
"Only complete the specific task assigned below.\n\n"
)
initial_messages: list = [
*_filter_fork_messages(parent_msgs),
{"role": "user", "content": _FORK_MARKER + prompt},
]
else:
initial_messages = [{"role": "user", "content": prompt}]

async for chunk in agent.agent.astream(
{"messages": [{"role": "user", "content": prompt}]},
{"messages": initial_messages},
config=config,
stream_mode="updates",
):
Expand Down
Loading
Loading