This commit is contained in:
Timothy Jaeryang Baek
2026-06-13 13:12:25 +02:00
Unverified
parent aedafae389
commit 82d5167e99
6 changed files with 453 additions and 109 deletions
@@ -0,0 +1,113 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { getAdminConfig, updateConfig } from '$lib/apis/admin';
import { t } from '$lib/i18n';
import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
let loading = $state(true);
let saving = $state(false);
let enabled = $state(false);
let maxConcurrent = $state(3);
let maxIterations = $state(30);
let maxOutput = $state(30000);
let systemPrompt = $state('');
onMount(async () => {
try {
const config = await getAdminConfig();
enabled = config['subagents.enabled'] === true || config['subagents.enabled'] === 'true';
maxConcurrent = Number(config['subagents.max_concurrent']) || 3;
maxIterations = Number(config['subagents.max_iterations']) || 30;
maxOutput = Number(config['subagents.max_output']) || 30000;
systemPrompt = (config['subagents.system_prompt'] as string) || '';
} catch {
toast.error($t('admin.failedToLoadConfig'));
}
loading = false;
});
async function save() {
saving = true;
try {
await updateConfig({
'subagents.enabled': enabled,
'subagents.max_concurrent': maxConcurrent,
'subagents.max_iterations': maxIterations,
'subagents.max_output': maxOutput,
'subagents.system_prompt': systemPrompt
});
toast.success($t('settings.saved'));
} catch {
toast.error($t('admin.failedToSave'));
} finally {
saving = false;
}
}
</script>
<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$t('admin.subagents')}</h2>
{#if loading}
<div class="flex justify-center py-8"><Spinner size={16} /></div>
{:else}
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.subagentsEnabled')}</span>
<ToggleSwitch value={enabled} onchange={(v) => { enabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{$t('admin.subagentsHint')}
</p>
{#if enabled}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-concurrent">{$t('admin.subagentsMaxConcurrent')}</label>
<div class="flex items-center gap-1.5 mt-1">
<input id="sa-concurrent" type="number" bind:value={maxConcurrent} min="1" max="10"
class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<span class="text-[11px] text-gray-400 dark:text-gray-600">{$t('admin.subagentsMaxConcurrentHint')}</span>
</div>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-iterations">{$t('admin.subagentsMaxIterations')}</label>
<div class="flex items-center gap-1.5 mt-1">
<input id="sa-iterations" type="number" bind:value={maxIterations} min="1" max="100"
class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<span class="text-[11px] text-gray-400 dark:text-gray-600">{$t('admin.subagentsMaxIterationsHint')}</span>
</div>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-output">{$t('admin.subagentsMaxOutput')}</label>
<div class="flex items-center gap-1.5 mt-1">
<input id="sa-output" type="number" bind:value={maxOutput} min="1000" max="100000" step="1000"
class="w-20 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
<span class="text-[11px] text-gray-400 dark:text-gray-600">chars</span>
</div>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-prompt">{$t('admin.subagentsSystemPrompt')}</label>
<textarea id="sa-prompt" bind:value={systemPrompt} rows="4"
placeholder={$t('admin.subagentsSystemPromptPlaceholder')}
class="w-full mt-1 px-2 py-1.5 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors resize-y font-mono" />
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">{$t('admin.subagentsSystemPromptHint')}</p>
</div>
{/if}
</div>
<!-- Save -->
<div class="mt-auto pt-6 flex justify-end">
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
onclick={() => save()}
disabled={saving}
>{$t('settings.save')}</button>
</div>
{/if}
</div>
@@ -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' }
]);
</script>
@@ -137,6 +140,8 @@
<AdminWeb />
{:else if activeTab === 'toolservers'}
<ToolServers />
{:else if activeTab === 'subagents'}
<Subagents />
{/if}
</div>
</Modal>
@@ -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('_');
+13 -1
View File
@@ -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."
}
+172 -105
View File
@@ -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:
+144 -1
View File
@@ -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