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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A self-hosted personal AI agent that runs in a single Docker container. MPA acts
- **Personae** — Swappable agent identities (own character, skill/tool scope, voice). Bind one per chat — and, on Telegram, per forum topic — so several run concurrently, each with its own isolated context. Give a persona its own bot token and it becomes a separate Telegram contact (bot-per-persona)
- **Memory** — Two-tier system: permanent long-term facts and expiring short-term context, both extracted automatically from conversations
- **Scheduled tasks** — Cron-based jobs for morning briefings, email checks, contact sync, and custom tasks
- **Subagents** — Delegate a scoped subtask to a sub-loop under a chosen persona, on demand or scheduled. Runs sync (result returned in-turn) or in the background; a finished background batch is distilled by a summary inference into a one-line chat notification + a concise context digest (raw output never reaches the user or the agent's context). The agent sizes each run (steps / token budget / thinking effort) and defaults the persona to its own; scope is a subset of the caller's (inherit-never-widen). Monitor and cancel from Telegram (`/jobs`) or the admin UI
- **Voice** — Speech-to-text (faster-whisper) and text-to-speech (edge-tts)
- **Web search** — Tavily integration for real-time information
- **Browser automation** — Optional headless browser (Playwright) to read JS-heavy pages and act on sites, with persistent logged-in profiles and per-domain approval (off by default)
Expand Down
128 changes: 110 additions & 18 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,12 @@ async def _persona_editor_ctx() -> dict:
store = await _skills_store_from_config(config_store)
all_skills = [s["name"] for s in await store.list_skills()]
artifacts = await store_from_config(config_store)
return {"all_skills": all_skills, "all_tools": gateable_tools_for(artifacts.enabled)}
sub_enabled = await config_store.get("subagents.enabled")
sub_on = sub_enabled is None or sub_enabled == "true"
return {
"all_skills": all_skills,
"all_tools": gateable_tools_for(artifacts.enabled, subagents_enabled=sub_on),
}

@app.get("/admin/personae/new", response_model=None)
async def admin_persona_new() -> Response:
Expand Down Expand Up @@ -1031,30 +1036,30 @@ async def partial_llm() -> HTMLResponse:
deepseek_vaulted = _is_vault_ref(deepseek_api_key)
model = await config_store.get("agent.model") or "claude-4-6-sonnet"
thinking_level = await config_store.get("agent.thinking_level") or ""
extraction_provider = await config_store.get("memory.extraction_provider") or "anthropic"
extraction_model = await config_store.get("memory.extraction_model") or "claude-haiku-4-5"
extraction_provider = await config_store.get("memory.extraction_provider") or "deepseek"
extraction_model = await config_store.get("memory.extraction_model") or "deepseek-v4-flash"
consolidation_provider = (
await config_store.get("memory.consolidation_provider") or "anthropic"
await config_store.get("memory.consolidation_provider") or "deepseek"
)
consolidation_model = (
await config_store.get("memory.consolidation_model") or "claude-haiku-4-5"
await config_store.get("memory.consolidation_model") or "deepseek-v4-flash"
)
extraction_thinking_level = await config_store.get("memory.extraction_thinking_level") or ""
consolidation_thinking_level = (
await config_store.get("memory.consolidation_thinking_level") or ""
)
gd_enabled = await config_store.get("goal_decomposition.enabled")
gd_enabled = gd_enabled if gd_enabled is not None else "true"
gd_provider = await config_store.get("goal_decomposition.provider") or "anthropic"
gd_model = await config_store.get("goal_decomposition.model") or "claude-haiku-4-5"
gd_provider = await config_store.get("goal_decomposition.provider") or "deepseek"
gd_model = await config_store.get("goal_decomposition.model") or "deepseek-v4-flash"
tr_enabled = await config_store.get("task_reflection.enabled")
tr_enabled = tr_enabled if tr_enabled is not None else "true"
tr_provider = await config_store.get("task_reflection.provider") or "anthropic"
tr_model = await config_store.get("task_reflection.model") or "claude-haiku-4-5"
tr_provider = await config_store.get("task_reflection.provider") or "deepseek"
tr_model = await config_store.get("task_reflection.model") or "deepseek-v4-flash"
gd_thinking_level = await config_store.get("goal_decomposition.thinking_level") or ""
tr_thinking_level = await config_store.get("task_reflection.thinking_level") or ""
compaction_provider = await config_store.get("compaction.provider") or "anthropic"
compaction_model = await config_store.get("compaction.model") or "claude-haiku-4-5"
compaction_provider = await config_store.get("compaction.provider") or "deepseek"
compaction_model = await config_store.get("compaction.model") or "deepseek-v4-flash"
compaction_thinking_level = await config_store.get("compaction.thinking_level") or ""
vision_enabled = await config_store.get("vision.enabled")
vision_enabled = vision_enabled if vision_enabled is not None else "false"
Expand Down Expand Up @@ -1151,6 +1156,22 @@ async def partial_tools() -> HTMLResponse:

artifacts = await store_from_config(config_store)

# Subagents (issue #15) — keys may be absent on a store seeded before the
# feature existed, so fall back to the SubagentsConfig defaults.
sub_enabled = await config_store.get("subagents.enabled")
sub_enabled = sub_enabled if sub_enabled is not None else "true"
sub_recursion = await config_store.get("subagents.recursion_depth") or "3"
sub_steps = await config_store.get("subagents.max_steps") or "12"
sub_tokens = await config_store.get("subagents.token_budget") or "100000"
sub_concurrent = await config_store.get("subagents.max_concurrent") or "3"
# Result-summary inference (notification + context digest) for finished
# background batches — fast/cheap model by default.
ss_enabled = await config_store.get("subagent_summary.enabled")
ss_enabled = ss_enabled if ss_enabled is not None else "true"
ss_provider = await config_store.get("subagent_summary.provider") or "deepseek"
ss_model = await config_store.get("subagent_summary.model") or "deepseek-v4-flash"
ss_thinking = await config_store.get("subagent_summary.thinking_level") or ""

return _render_partial(
"partials/tools.html",
tools=tool_registry(),
Expand All @@ -1166,6 +1187,15 @@ async def partial_tools() -> HTMLResponse:
artifacts_enabled="true" if artifacts.enabled else "false",
artifacts_directory=str(artifacts.dir),
artifacts_ttl_hours=str(artifacts.ttl_hours),
subagents_enabled=sub_enabled,
subagents_recursion_depth=sub_recursion,
subagents_max_steps=sub_steps,
subagents_token_budget=sub_tokens,
subagents_max_concurrent=sub_concurrent,
summary_enabled=ss_enabled,
summary_provider=ss_provider,
summary_model=ss_model,
summary_thinking_level=ss_thinking,
)

@app.post("/tools/gh/test", dependencies=[Depends(auth)])
Expand Down Expand Up @@ -1423,6 +1453,7 @@ def _get_jobs_list() -> list[dict]:
"run_at": j.get("run_at", ""),
"description": j.get("description", ""),
"created_by": j.get("created_by", ""),
"persona": j.get("persona", ""),
}
)
return jobs
Expand Down Expand Up @@ -1451,6 +1482,7 @@ async def upsert_job(request: Request) -> HTMLResponse:
task = str(body.get("task", "")).strip()
channel = str(body.get("channel", "telegram")).strip()
description = str(body.get("description", "")).strip()
persona = str(body.get("persona", "")).strip()

if not job_id:
raise HTTPException(400, "Job ID is required")
Expand All @@ -1462,10 +1494,13 @@ async def upsert_job(request: Request) -> HTMLResponse:
else:
if not run_at:
raise HTTPException(400, "Run-at datetime is required for one-time jobs")
if job_type not in ("agent", "agent_silent", "system", "memory_consolidation"):
if job_type not in ("agent", "agent_silent", "system", "memory_consolidation", "subagent"):
raise HTTPException(400, f"Invalid job type: {job_type}")
if job_type != "memory_consolidation" and not task:
raise HTTPException(400, "Task is required for agent/system jobs")
raise HTTPException(400, "Task is required for agent/system/subagent jobs")
# Persona only applies to subagent jobs — don't persist a stray value on others.
if job_type != "subagent":
persona = ""

if schedule == "cron":
from core.scheduler import _parse_cron
Expand Down Expand Up @@ -1494,6 +1529,7 @@ async def upsert_job(request: Request) -> HTMLResponse:
status="active",
created_by="admin",
description=description,
persona=persona,
)

# Sync with APScheduler if the agent is running
Expand Down Expand Up @@ -1564,7 +1600,12 @@ async def run_job_now(request: Request) -> HTMLResponse:

import asyncio

from core.scheduler import run_agent_task, run_memory_consolidation, run_system_command
from core.scheduler import (
run_agent_task,
run_memory_consolidation,
run_subagent_task,
run_system_command,
)

job_type = job.get("type", "agent")
task = job.get("task", "")
Expand All @@ -1579,6 +1620,15 @@ async def run_job_now(request: Request) -> HTMLResponse:
asyncio.create_task(run_system_command(command=task))
elif job_type == "memory_consolidation":
asyncio.create_task(run_memory_consolidation())
elif job_type == "subagent":
asyncio.create_task(
run_subagent_task(
persona=job.get("persona", ""),
task=task,
channel=channel_name,
job_id=job_id,
)
)
else:
return HTMLResponse('<span class="alert-error">Unknown job type</span>')

Expand All @@ -1588,6 +1638,44 @@ async def run_job_now(request: Request) -> HTMLResponse:
"for output</span>"
)

# ── Subagent runs (issue #15) ──────────────────────────────────────

def _subagent_runs() -> list:
"""Live subagent runs from the running agent (newest first)."""
agent = agent_state.agent
if not agent:
return []
return agent.subagents.list_runs()

@app.get("/partials/subagent-runs", dependencies=[Depends(auth)])
async def partial_subagent_runs() -> HTMLResponse:
"""Subagent runs card grid — polled by the Jobs tab for live status."""
return _render_partial(
"partials/subagent_runs.html",
runs=_subagent_runs(),
agent_running=agent_state.agent is not None,
)

@app.post("/subagents/cancel", dependencies=[Depends(auth)])
async def cancel_subagent(request: Request) -> HTMLResponse:
"""Cancel a running subagent. Returns the refreshed runs partial."""
agent = agent_state.agent
if not agent:
raise HTTPException(503, "Agent not running")
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" in content_type:
body = await request.form()
else:
body = await request.json()
run_id = str(body.get("run_id", "")).strip()
if not run_id:
raise HTTPException(400, "Missing 'run_id' in request body")
agent.subagents.cancel(run_id)
log.info("Subagent %r cancelled via admin", run_id)
return _render_partial(
"partials/subagent_runs.html", runs=_subagent_runs(), agent_running=True
)

# ── Config API ─────────────────────────────────────────────────────

@app.get("/config", dependencies=[Depends(auth)])
Expand Down Expand Up @@ -3337,14 +3425,18 @@ def _config_requires_restart(values: dict) -> bool:
"web_search",
"manage_jobs",
"write_artifact",
"spawn_subagent",
]


def gateable_tools_for(artifacts_enabled: bool) -> list[str]:
def gateable_tools_for(artifacts_enabled: bool, subagents_enabled: bool = True) -> list[str]:
"""GATEABLE_TOOLS minus tools whose feature is globally disabled."""
if artifacts_enabled:
return list(GATEABLE_TOOLS)
return [t for t in GATEABLE_TOOLS if t != "write_artifact"]
out = list(GATEABLE_TOOLS)
if not artifacts_enabled:
out = [t for t in out if t != "write_artifact"]
if not subagents_enabled:
out = [t for t in out if t != "spawn_subagent"]
return out


# ---------------------------------------------------------------------------
Expand Down
23 changes: 21 additions & 2 deletions api/templates/partials/jobs.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
{% endif %}
</div>

{# Subagent runs (issue #15) — live status, polled every 3s #}
<div class="mb-5">
<h3 class="text-xs text-muted uppercase tracking-wider mb-2">Subagent runs</h3>
<div id="subagent-runs"
hx-get="/partials/subagent-runs" hx-trigger="load, every 3s" hx-swap="innerHTML">
<p class="text-muted text-xs">Loading…</p>
</div>
</div>

{# Jobs table #}
<div class="overflow-x-auto">
<table class="table">
Expand Down Expand Up @@ -119,9 +128,16 @@ <h3 class="text-xs text-muted uppercase tracking-wider mt-5 mb-2" x-text="editin
<option value="agent">agent — natural language task</option>
<option value="agent_silent">agent_silent — quiet when nothing new</option>
<option value="system">system — CLI command</option>
<option value="subagent">subagent — run task under a persona</option>
<option value="memory_consolidation">memory_consolidation</option>
</select>
</div>
<div x-show="form.type === 'subagent'">
<label class="label">Persona</label>
<input type="text" class="input-sm" name="persona" x-model="form.persona"
placeholder="coding-helper">
<span class="hint">Persona the subagent adopts (blank = default identity)</span>
</div>
<div>
<label class="label">Channel</label>
<select class="select" name="channel" x-model="form.channel"
Expand All @@ -139,6 +155,7 @@ <h3 class="text-xs text-muted uppercase tracking-wider mt-5 mb-2" x-text="editin
<span class="hint" x-show="form.type === 'agent'">Natural language instruction for the agent</span>
<span class="hint" x-show="form.type === 'agent_silent'">Only sends when there are updates; otherwise stays quiet</span>
<span class="hint" x-show="form.type === 'system'">Shell command to execute</span>
<span class="hint" x-show="form.type === 'subagent'">Task for the subagent to run under the chosen persona</span>
<span class="hint" x-show="form.type === 'memory_consolidation'">No task needed — runs memory consolidation automatically</span>
</div>
</div>
Expand Down Expand Up @@ -181,7 +198,8 @@ <h3 class="text-xs text-muted uppercase tracking-wider mt-5 mb-2" x-text="editin
type: 'agent',
task: '',
channel: 'telegram',
description: ''
description: '',
persona: ''
},
editJob(job) {
this.editing = true;
Expand All @@ -193,14 +211,15 @@ <h3 class="text-xs text-muted uppercase tracking-wider mt-5 mb-2" x-text="editin
this.form.task = job.task || '';
this.form.channel = job.channel || 'telegram';
this.form.description = job.description || '';
this.form.persona = job.persona || '';
// scroll to form
this.$nextTick(() => {
document.querySelector('form[hx-post="/jobs"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
},
cancelEdit() {
this.editing = false;
this.form = { id: '', schedule: 'cron', cron: '', run_at: '', type: 'agent', task: '', channel: 'telegram', description: '' };
this.form = { id: '', schedule: 'cron', cron: '', run_at: '', type: 'agent', task: '', channel: 'telegram', description: '', persona: '' };
}
};
}
Expand Down
20 changes: 10 additions & 10 deletions api/templates/partials/llm.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,23 @@
{{ grok_base_url|default('', true)|tojson|forceescape }},
{{ deepseek_api_key|default('', true)|tojson|forceescape }},
{{ deepseek_base_url|default('', true)|tojson|forceescape }},
{{ extraction_provider|default('anthropic', true)|tojson|forceescape }},
{{ extraction_model|default('claude-haiku-4-5', true)|tojson|forceescape }},
{{ consolidation_provider|default('anthropic', true)|tojson|forceescape }},
{{ consolidation_model|default('claude-haiku-4-5', true)|tojson|forceescape }},
{{ extraction_provider|default('deepseek', true)|tojson|forceescape }},
{{ extraction_model|default('deepseek-v4-flash', true)|tojson|forceescape }},
{{ consolidation_provider|default('deepseek', true)|tojson|forceescape }},
{{ consolidation_model|default('deepseek-v4-flash', true)|tojson|forceescape }},
{{ gd_enabled|default('true', true)|tojson|forceescape }},
{{ gd_provider|default('anthropic', true)|tojson|forceescape }},
{{ gd_model|default('claude-haiku-4-5', true)|tojson|forceescape }},
{{ gd_provider|default('deepseek', true)|tojson|forceescape }},
{{ gd_model|default('deepseek-v4-flash', true)|tojson|forceescape }},
{{ tr_enabled|default('true', true)|tojson|forceescape }},
{{ tr_provider|default('anthropic', true)|tojson|forceescape }},
{{ tr_model|default('claude-haiku-4-5', true)|tojson|forceescape }},
{{ tr_provider|default('deepseek', true)|tojson|forceescape }},
{{ tr_model|default('deepseek-v4-flash', true)|tojson|forceescape }},
{{ prompt_tool_usage_override|default('', true)|tojson|forceescape }},
{{ prompt_history_override|default('', true)|tojson|forceescape }},
{{ default_tool_usage|default('', true)|tojson|forceescape }},
{{ default_history_handling|default('', true)|tojson|forceescape }},
{{ prompt_capture_enabled|default(false, true)|tojson|forceescape }},
{{ compaction_provider|default('anthropic', true)|tojson|forceescape }},
{{ compaction_model|default('claude-haiku-4-5', true)|tojson|forceescape }},
{{ compaction_provider|default('deepseek', true)|tojson|forceescape }},
{{ compaction_model|default('deepseek-v4-flash', true)|tojson|forceescape }},
{{ thinking_level|default('', true)|tojson|forceescape }},
{{ extraction_thinking_level|default('', true)|tojson|forceescape }},
{{ consolidation_thinking_level|default('', true)|tojson|forceescape }},
Expand Down
Loading
Loading