diff --git a/cptr/frontend/src/lib/components/Admin/Subagents.svelte b/cptr/frontend/src/lib/components/Admin/Subagents.svelte
new file mode 100644
index 0000000..a6c0dd1
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/Subagents.svelte
@@ -0,0 +1,113 @@
+
+
+
+
{$t('admin.subagents')}
+
+ {#if loading}
+
+ {:else}
+
+
+
+ {$t('admin.subagentsHint')}
+
+
+ {#if enabled}
+
+
+
+
+ {$t('admin.subagentsMaxConcurrentHint')}
+
+
+
+
+
+
+
+ {$t('admin.subagentsMaxIterationsHint')}
+
+
+
+
+
+
+
+ chars
+
+
+
+
+
+
+
{$t('admin.subagentsSystemPromptHint')}
+
+ {/if}
+
+
+
+
+
+
+ {/if}
+
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte
index 3c341f5..03b9b9c 100644
--- a/cptr/frontend/src/lib/components/SettingsModal.svelte
+++ b/cptr/frontend/src/lib/components/SettingsModal.svelte
@@ -13,6 +13,7 @@
import AudioSettings from './Admin/AudioSettings.svelte';
import AdminWeb from './Admin/Web.svelte';
import ToolServers from './Admin/ToolServers.svelte';
+ import Subagents from './Admin/Subagents.svelte';
import { session } from '$lib/session';
import { t } from '$lib/i18n';
@@ -28,7 +29,8 @@
| 'gateway'
| 'audio'
| 'web'
- | 'toolservers';
+ | 'toolservers'
+ | 'subagents';
interface Props {
onclose: () => void;
@@ -56,7 +58,8 @@
{ id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' },
{ id: 'audio', label: $t('admin.audio.title'), icon: 'microphone' },
{ id: 'web', label: $t('admin.web'), icon: 'globe' },
- { id: 'toolservers', label: $t('admin.toolServers'), icon: 'plug' }
+ { id: 'toolservers', label: $t('admin.toolServers'), icon: 'plug' },
+ { id: 'subagents', label: $t('admin.subagents'), icon: 'user' }
]);
@@ -137,6 +140,8 @@
{:else if activeTab === 'toolservers'}
+ {:else if activeTab === 'subagents'}
+
{/if}
diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
index 1da33d5..ac5e2bc 100644
--- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
+++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
@@ -178,6 +178,10 @@
return _t('chat.tool.fetchUrlFallback');
}
}
+ case 'delegate_task': {
+ const t = args.task || '?';
+ return `Sub-agent: "${t.length > 60 ? t.slice(0, 60) + '…' : t}"`;
+ }
default: {
// External tool: {server_id}_{tool_name} → "tool_name (server_id)"
const idx = name.indexOf('_');
diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json
index 27c8f4e..ec0c45b 100644
--- a/cptr/frontend/src/lib/i18n/locales/en.json
+++ b/cptr/frontend/src/lib/i18n/locales/en.json
@@ -680,6 +680,18 @@
"toolServers.updated": "Tool server updated",
"toolServers.deleted": "Tool server deleted",
"toolServers.saveFailed": "Failed to save tool server",
- "toolServers.deleteFailed": "Failed to delete tool server"
+ "toolServers.deleteFailed": "Failed to delete tool server",
+
+ "admin.subagents": "Sub-agents",
+ "admin.subagentsEnabled": "Enable sub-agents",
+ "admin.subagentsHint": "Allow the AI to delegate tasks to sub-agents. Each sub-agent creates a real chat with full tool access. Uses additional LLM calls.",
+ "admin.subagentsMaxConcurrent": "Max concurrent",
+ "admin.subagentsMaxConcurrentHint": "simultaneous sub-agents",
+ "admin.subagentsMaxIterations": "Max iterations",
+ "admin.subagentsMaxIterationsHint": "tool loops per sub-agent",
+ "admin.subagentsMaxOutput": "Max output",
+ "admin.subagentsSystemPrompt": "System prompt",
+ "admin.subagentsSystemPromptPlaceholder": "You are a sub-agent...",
+ "admin.subagentsSystemPromptHint": "Leave empty for the built-in default."
}
diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py
index 705b250..556de3b 100644
--- a/cptr/utils/chat_task.py
+++ b/cptr/utils/chat_task.py
@@ -861,8 +861,12 @@ async def run_chat_task(
if not skills:
tools = [t for t in tools if t["name"] != "view_skill"]
- # Parse $skill-name mentions from the user message to auto-activate skills
+ # Strip delegate_task from sub-agent chats (depth limit = 1)
chat_obj = await Chat.get_by_id(chat_id)
+ if chat_obj and (chat_obj.meta or {}).get("subagent"):
+ tools = [t for t in tools if t["name"] != "delegate_task"]
+
+ # Parse $skill-name mentions from the user message to auto-activate skills
chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {}
attached_skill_ids: list[str] = []
if skills and messages:
@@ -1024,6 +1028,7 @@ async def run_chat_task(
)
restart = False
+ pending_calls: list[dict] = [] # Collect tool calls from this response
async for event in stream:
if event["type"] == "text_delta":
@@ -1033,93 +1038,8 @@ async def run_chat_task(
_sync_state()
elif event["type"] == "tool_call":
- # Flush any text before the tool call
- flushed_item = _flush_text()
-
- name = event["name"]
- tool = ALL_TOOLS.get(name)
- item = {
- "type": "function_call",
- "id": str(uuid.uuid4()),
- "call_id": event["call_id"],
- "fc_id": event.get("id", ""),
- "name": name,
- "arguments": event["arguments"],
- }
-
- should_auto = approval_mode == "full" or (
- approval_mode == "auto" and tool and tool["auto"]
- )
-
- if should_auto:
- # Show tool call in progress BEFORE execution
- item["status"] = "in_progress"
- output_items.append(item)
- if flushed_item:
- await emit(output=flushed_item)
- await emit(output=item)
- _sync_state()
-
- if name == "create_artifact":
- result = await create_artifact(
- **event["arguments"], workspace=workspace
- )
- else:
- result = await execute_tool(
- name,
- event["arguments"],
- {
- "workspace": workspace,
- "user_id": user_id,
- "model_id": model,
- "chat_id": chat_id,
- },
- )
-
- # Update status to completed
- item["status"] = "completed"
- result_item = {
- "type": "function_call_output",
- "call_id": event["call_id"],
- "output": result,
- }
- output_items.append(result_item)
- await emit(output=item)
- await emit(output=result_item)
- _sync_state()
-
- # Artifact UI card: detect create_artifact or create_file with artifact_type
- artifact_item = build_artifact_item(name, event["arguments"], result)
- if artifact_item:
- output_items.append(artifact_item)
- await emit(output=artifact_item)
- _sync_state()
-
- # Persist intermediate state so content survives crashes/errors
- await ChatMessage.update(message_id, content=content, output=output_items)
-
- # Append to messages for next iteration
- _append_tool_to_messages(messages, event, result, provider)
- new_messages_since += 2 # tool_call + tool_result
- restart = True
- break
-
- else:
- # Needs approval, persist and stop
- item["status"] = "pending"
- output_items.append(item)
- await ChatMessage.update(
- message_id,
- content=content,
- output=output_items,
- done=False,
- )
- if flushed_item:
- await emit(output=flushed_item)
- await emit(output=item)
- _task_state.pop(message_id, None)
- await emit(done=True)
- return
+ # Collect tool call — don't execute yet
+ pending_calls.append(event)
elif event["type"] == "usage":
_flush_text()
@@ -1130,28 +1050,175 @@ async def run_chat_task(
)
last_usage = usage
new_messages_since = 0
- logger.info(
- "[task %s] save (usage): content=%d chars, output=%d items, types=%s",
- message_id[:8],
- len(content),
- len(output_items),
- [i.get("type") for i in output_items],
- )
- await ChatMessage.update(
- message_id,
- content=content,
- output=output_items,
- usage=usage,
- done=True,
- )
- _task_state.pop(message_id, None)
- await _emit_done()
- return
+
+ if not pending_calls:
+ # No tool calls — final response, we're done
+ logger.info(
+ "[task %s] save (usage): content=%d chars, output=%d items, types=%s",
+ message_id[:8],
+ len(content),
+ len(output_items),
+ [i.get("type") for i in output_items],
+ )
+ await ChatMessage.update(
+ message_id,
+ content=content,
+ output=output_items,
+ usage=usage,
+ done=True,
+ )
+ _task_state.pop(message_id, None)
+ await _emit_done()
+ return
elif event["type"] == "done":
# Stream ended without explicit usage
pass
+ # ── Process collected tool calls ────────────────────
+ if pending_calls:
+ flushed_item = _flush_text()
+
+ tool_ctx = {
+ "workspace": workspace,
+ "user_id": user_id,
+ "model_id": model,
+ "chat_id": chat_id,
+ "connection": connection,
+ }
+
+ # Check if any call needs approval
+ needs_approval = None
+ for tc in pending_calls:
+ name = tc["name"]
+ tool = ALL_TOOLS.get(name)
+ should_auto = approval_mode == "full" or (
+ approval_mode == "auto" and tool and tool["auto"]
+ )
+ if not should_auto:
+ needs_approval = tc
+ break
+
+ if needs_approval:
+ # First non-auto tool stops the loop for approval
+ tc = needs_approval
+ item = {
+ "type": "function_call",
+ "id": str(uuid.uuid4()),
+ "call_id": tc["call_id"],
+ "fc_id": tc.get("id", ""),
+ "name": tc["name"],
+ "arguments": tc["arguments"],
+ "status": "pending",
+ }
+ output_items.append(item)
+ await ChatMessage.update(
+ message_id,
+ content=content,
+ output=output_items,
+ done=False,
+ )
+ if flushed_item:
+ await emit(output=flushed_item)
+ await emit(output=item)
+ _task_state.pop(message_id, None)
+ await emit(done=True)
+ return
+
+ # All calls are auto-approved — build UI items
+ call_items: list[tuple[dict, dict]] = [] # (event, ui_item)
+ for tc in pending_calls:
+ item = {
+ "type": "function_call",
+ "id": str(uuid.uuid4()),
+ "call_id": tc["call_id"],
+ "fc_id": tc.get("id", ""),
+ "name": tc["name"],
+ "arguments": tc["arguments"],
+ "status": "in_progress",
+ }
+ output_items.append(item)
+ call_items.append((tc, item))
+
+ # Emit all as in_progress
+ if flushed_item:
+ await emit(output=flushed_item)
+ for _, item in call_items:
+ await emit(output=item)
+ _sync_state()
+
+ # Separate delegate_task (concurrent) from others (sequential)
+ delegate_indices = [i for i, (tc, _) in enumerate(call_items) if tc["name"] == "delegate_task"]
+ other_indices = [i for i, (tc, _) in enumerate(call_items) if tc["name"] != "delegate_task"]
+
+ # Execute non-delegate tools sequentially first
+ for idx in other_indices:
+ tc, item = call_items[idx]
+ if tc["name"] == "create_artifact":
+ result = await create_artifact(**tc["arguments"], workspace=workspace)
+ else:
+ result = await execute_tool(tc["name"], tc["arguments"], tool_ctx)
+
+ item["status"] = "completed"
+ result_item = {
+ "type": "function_call_output",
+ "call_id": tc["call_id"],
+ "output": result,
+ }
+ output_items.append(result_item)
+ await emit(output=item)
+ await emit(output=result_item)
+ _sync_state()
+
+ artifact_item = build_artifact_item(tc["name"], tc["arguments"], result)
+ if artifact_item:
+ output_items.append(artifact_item)
+ await emit(output=artifact_item)
+ _sync_state()
+
+ _append_tool_to_messages(messages, tc, result, provider)
+ new_messages_since += 2
+
+ # Execute delegate_task calls concurrently, emit each as it completes
+ if delegate_indices:
+ # Create tasks, mapping task → index
+ inflight: dict[asyncio.Task, int] = {}
+ for idx in delegate_indices:
+ tc, _ = call_items[idx]
+ task = asyncio.create_task(
+ execute_tool(tc["name"], tc["arguments"], tool_ctx)
+ )
+ inflight[task] = idx
+
+ while inflight:
+ done_set, _ = await asyncio.wait(
+ inflight.keys(), return_when=asyncio.FIRST_COMPLETED
+ )
+ for task in done_set:
+ idx = inflight.pop(task)
+ tc, item = call_items[idx]
+ try:
+ result = task.result()
+ except Exception as e:
+ result = f"Error: {e}"
+
+ item["status"] = "completed"
+ result_item = {
+ "type": "function_call_output",
+ "call_id": tc["call_id"],
+ "output": result,
+ }
+ output_items.append(result_item)
+ await emit(output=item)
+ await emit(output=result_item)
+ _sync_state()
+ _append_tool_to_messages(messages, tc, result, provider)
+ new_messages_since += 2
+
+ # Persist after all tool calls
+ await ChatMessage.update(message_id, content=content, output=output_items)
+ restart = True
+
if not restart:
flushed_item = _flush_text()
if flushed_item:
diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py
index 4cb05f2..154f3ec 100644
--- a/cptr/utils/tools.py
+++ b/cptr/utils/tools.py
@@ -1208,8 +1208,149 @@ BROWSER_TOOLS: dict[str, dict] = {
"browser_evaluate": {"fn": browser_evaluate, "auto": False},
}
+
+# ── Sub-agent ───────────────────────────────────────────────
+
+_DEFAULT_SUBAGENT_SYSTEM = """You are a sub-agent working on a specific task assigned by the lead agent.
+
+You have full access to the workspace — you can read, write, edit files, and run commands.
+Focus exclusively on your assigned task. Do NOT work on anything outside your scope.
+
+When done, end with a clear summary:
+- What you did
+- What files you changed (if any)
+- Any issues or open questions
+"""
+
+_subagent_semaphore: asyncio.Semaphore | None = None
+
+
+async def _get_subagent_config() -> dict:
+ """Load sub-agent settings from config with defaults."""
+ from cptr.models import Config
+
+ return {
+ "max_concurrent": int(await Config.get("subagents.max_concurrent") or 3),
+ "max_iterations": int(await Config.get("subagents.max_iterations") or 30),
+ "max_output": int(await Config.get("subagents.max_output") or 30_000),
+ "system_prompt": (await Config.get("subagents.system_prompt"))
+ or _DEFAULT_SUBAGENT_SYSTEM,
+ }
+
+
+def _truncate_output(text: str, max_chars: int) -> str:
+ """Truncate text to max_chars, appending a note if truncated."""
+ if len(text) <= max_chars:
+ return text
+ return text[:max_chars] + "\n\n[output truncated]"
+
+
+async def delegate_task(
+ task: str,
+ context: str = "",
+ *,
+ __context__: dict,
+) -> str:
+ """Delegate a task to a sub-agent. The sub-agent has full access to read, write, edit files, and run commands. Use for parallel work — call multiple times in one response to run tasks concurrently.
+
+ :param task: What the sub-agent should do.
+ :param context: Optional context (e.g. relevant file paths, decisions made so far).
+ """
+ global _subagent_semaphore
+ config = await _get_subagent_config()
+ if _subagent_semaphore is None:
+ _subagent_semaphore = asyncio.Semaphore(config["max_concurrent"])
+
+ async with _subagent_semaphore:
+ return await _run_subagent_chat(
+ task=task,
+ context=context,
+ workspace=__context__["workspace"],
+ connection=__context__["connection"],
+ model=__context__["model_id"],
+ user_id=__context__["user_id"],
+ parent_chat_id=__context__["chat_id"],
+ config=config,
+ )
+
+
+async def _run_subagent_chat(
+ task: str,
+ context: str,
+ workspace: str,
+ connection: dict,
+ model: str,
+ user_id: str,
+ parent_chat_id: str,
+ config: dict,
+) -> str:
+ """Create a real chat and run the agent loop on it."""
+ from cptr.models import Chat, ChatMessage
+ from cptr.utils.chat_task import run_chat_task
+ from cptr.utils.config import now_ms
+
+ # Build the user message content
+ user_content = f"{task}\n\n## Context\n{context}" if context else task
+
+ # Create a real chat, marked as a sub-agent
+ chat = await Chat.create(
+ user_id=user_id,
+ title=f"Sub-agent: {task[:60]}",
+ meta={
+ "workspace": workspace,
+ "subagent": True,
+ "parent_chat_id": parent_chat_id,
+ "params": {
+ "tool_approval_mode": "full", # auto-approve all tools
+ },
+ },
+ created_at=now_ms(),
+ )
+
+ # Create user message
+ user_msg = await ChatMessage.create(
+ chat_id=chat.id,
+ role="user",
+ content=user_content,
+ created_at=now_ms(),
+ )
+
+ # Create empty assistant message
+ assistant_msg = await ChatMessage.create(
+ chat_id=chat.id,
+ role="assistant",
+ content="",
+ parent_id=user_msg.id,
+ model=model,
+ done=False,
+ created_at=now_ms(),
+ )
+
+ await Chat.update_current_message(chat.id, assistant_msg.id, now_ms())
+
+ # Run the SAME agent loop as a normal chat — no special code
+ await run_chat_task(
+ message_id=assistant_msg.id,
+ chat_id=chat.id,
+ user_id=user_id,
+ connection=connection,
+ workspace=workspace,
+ model=model,
+ )
+
+ # Read back the completed message
+ result_msg = await ChatMessage.get_by_id(assistant_msg.id)
+ output = result_msg.content if result_msg else "Sub-agent produced no output."
+
+ return _truncate_output(output, config["max_output"])
+
+
+SUBAGENT_TOOLS: dict[str, dict] = {
+ "delegate_task": {"fn": delegate_task, "auto": True},
+}
+
# Combined lookup for execution and approval (always available regardless of config)
-ALL_TOOLS: dict[str, dict] = {**TOOLS, **BROWSER_TOOLS}
+ALL_TOOLS: dict[str, dict] = {**TOOLS, **BROWSER_TOOLS, **SUBAGENT_TOOLS}
# ── External tool servers ───────────────────────────────────
@@ -1424,6 +1565,8 @@ async def get_tool_list() -> list[dict]:
if (await Config.get("browser.enabled")) in (True, "true", "1"):
tools.update(BROWSER_TOOLS)
+ if (await Config.get("subagents.enabled")) in (True, "true", "1"):
+ tools.update(SUBAGENT_TOOLS)
except Exception:
pass