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
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install Himalaya CLI (pre-built Rust binary for email management)
RUN curl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | sh

# Install GitHub CLI (gh) from the official apt repository (Tools tab: gh)
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

Expand Down
68 changes: 68 additions & 0 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import asyncio
import collections
import hashlib
import json
Expand Down Expand Up @@ -36,6 +37,8 @@
DEFAULT_TOOL_USAGE_BLOCK,
build_prompt_sections,
)
from core.tools import registry as tool_registry
from core.tools import tool_env
from core.wacli import WacliManager

if TYPE_CHECKING:
Expand Down Expand Up @@ -545,6 +548,8 @@ async def _lifespan(app: FastAPI): # noqa: ANN001
_HISTORY_PREFIX = "history."
_EMAIL_PREFIX = "email."
_PROMPT_PREFIX = "prompt."
_TOOLS_PREFIX = "tools."
_COMPACTION_PREFIX = "compaction."

def _is_managed_key(key: str) -> bool:
"""Return True if this key is managed by a dedicated tab (not Config)."""
Expand All @@ -562,6 +567,8 @@ def _is_managed_key(key: str) -> bool:
_HISTORY_PREFIX,
_EMAIL_PREFIX,
_PROMPT_PREFIX,
_TOOLS_PREFIX,
_COMPACTION_PREFIX,
):
if key.startswith(prefix):
return True
Expand Down Expand Up @@ -801,6 +808,8 @@ async def partial_llm() -> HTMLResponse:
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"
compaction_provider = await config_store.get("compaction.provider") or "anthropic"
compaction_model = await config_store.get("compaction.model") or "claude-haiku-4-5"
prompt_tool_usage_override = await config_store.get("prompt.tool_usage_override") or ""
prompt_history_override = await config_store.get("prompt.history_handling_override") or ""
prompt_capture_enabled = await config_store.get("admin.capture_prompts")
Expand Down Expand Up @@ -830,13 +839,58 @@ async def partial_llm() -> HTMLResponse:
tr_enabled=tr_enabled,
tr_provider=tr_provider,
tr_model=tr_model,
compaction_provider=compaction_provider,
compaction_model=compaction_model,
prompt_tool_usage_override=prompt_tool_usage_override,
prompt_history_override=prompt_history_override,
default_tool_usage=DEFAULT_TOOL_USAGE_BLOCK,
default_history_handling=DEFAULT_HISTORY_HANDLING_BLOCK,
prompt_capture_enabled=prompt_capture_enabled,
)

@app.get("/partials/tools", dependencies=[Depends(auth)])
async def partial_tools() -> HTMLResponse:
"""Tools tab partial — manage optional external CLI tools (e.g. gh)."""
gh_enabled = await config_store.get("tools.gh.enabled")
gh_enabled = gh_enabled if gh_enabled is not None else "false"
gh_token = await config_store.get("tools.gh.token") or ""
return _render_partial(
"partials/tools.html",
tools=tool_registry(),
gh_enabled=gh_enabled,
gh_token=gh_token,
)

@app.post("/tools/gh/test", dependencies=[Depends(auth)])
async def test_gh_tool(request: Request) -> dict:
"""Verify a GitHub token by calling the GitHub API as that token."""
body = await request.json()
token = str(body.get("token", "")).strip()
if not token:
return {"ok": False, "error": "Token is required."}
try:
resp = await asyncio.to_thread(
http_requests.get,
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=10,
)
except Exception as exc: # noqa: BLE001 — surface any network error to the UI
return {"ok": False, "error": str(exc)}
if resp.status_code == 200:
login = resp.json().get("login", "")
return {"ok": True, "login": login}
if resp.status_code in (401, 403):
return {
"ok": False,
"error": "Token rejected by GitHub (invalid or insufficient scope).",
}
return {"ok": False, "error": f"GitHub returned HTTP {resp.status_code}."}

@app.get("/partials/search", dependencies=[Depends(auth)])
async def partial_search() -> HTMLResponse:
"""Search tab partial."""
Expand Down Expand Up @@ -890,10 +944,23 @@ async def partial_history() -> HTMLResponse:
"""History tab partial."""
mode = await config_store.get("history.mode") or "injection"
max_turns = await config_store.get("history.max_turns") or "10"
c_enabled = await config_store.get("compaction.enabled")
c_enabled = c_enabled if c_enabled is not None else "true"
c_threshold_type = await config_store.get("compaction.threshold_type") or "percent"
c_threshold_percent = await config_store.get("compaction.threshold_percent") or "80"
c_threshold_tokens = await config_store.get("compaction.threshold_tokens") or "150000"
c_context_window = await config_store.get("compaction.context_window") or "200000"
c_keep_recent_turns = await config_store.get("compaction.keep_recent_turns") or "4"
return _render_partial(
"partials/history.html",
mode=mode,
max_turns=max_turns,
compaction_enabled=c_enabled,
compaction_threshold_type=c_threshold_type,
compaction_threshold_percent=c_threshold_percent,
compaction_threshold_tokens=c_threshold_tokens,
compaction_context_window=c_context_window,
compaction_keep_recent_turns=c_keep_recent_turns,
)

@app.get("/partials/logs", dependencies=[Depends(auth)])
Expand Down Expand Up @@ -1174,6 +1241,7 @@ async def patch_config(body: ConfigPatchIn) -> dict:
new_config = await config_store.export_to_config()
agent.config = new_config
agent.llm = LLMClient.from_agent_config(new_config.agent)
agent.executor.tool_env = tool_env(new_config)
agent.history_mode = new_config.history.mode
agent.memory.long_term_limit = new_config.memory.long_term_limit
agent.reflections.max_reflections = new_config.task_reflection.max_reflections
Expand Down
6 changes: 5 additions & 1 deletion api/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
}
window.showToast = showToast;

function llmTab(provider, apiKey, model, openaiKey, openaiBaseUrl, googleKey, googleBaseUrl, grokKey, grokBaseUrl, deepseekKey, deepseekBaseUrl, extractionProvider, extractionModel, consolidationProvider, consolidationModel, gdEnabled, gdProvider, gdModel, trEnabled, trProvider, trModel, promptToolUsageOverride, promptHistoryOverride, defaultToolUsage, defaultHistoryHandling, promptCaptureEnabled) {
function llmTab(provider, apiKey, model, openaiKey, openaiBaseUrl, googleKey, googleBaseUrl, grokKey, grokBaseUrl, deepseekKey, deepseekBaseUrl, extractionProvider, extractionModel, consolidationProvider, consolidationModel, gdEnabled, gdProvider, gdModel, trEnabled, trProvider, trModel, promptToolUsageOverride, promptHistoryOverride, defaultToolUsage, defaultHistoryHandling, promptCaptureEnabled, compactionProvider, compactionModel) {
const currentProvider = provider || 'anthropic';
return {
providerOptions: [
Expand Down Expand Up @@ -97,6 +97,10 @@
gdResultOk: false,
trResult: '',
trResultOk: false,
compactionProvider: compactionProvider || 'anthropic',
compactionModel: compactionModel || 'claude-haiku-4-5',
compactionResult: '',
compactionResultOk: false,
result: '',
resultOk: false,
tests: {
Expand Down
5 changes: 5 additions & 0 deletions api/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ <h2 class="text-base text-accent">Agent Control</h2>
@click="select('search')">
Search
</button>
<button class="tab-link" :class="tab === 'tools' && 'tab-link-active'"
@click="select('tools')">
Tools
</button>
<button class="tab-link" :class="tab === 'permissions' && 'tab-link-active'"
@click="select('permissions')">
Permissions
Expand Down Expand Up @@ -675,6 +679,7 @@ <h2 class="text-base text-accent">Agent Control</h2>
history: '/partials/history',
channels: '/partials/channels',
search: '/partials/search',
tools: '/partials/tools',
permissions: '/partials/permissions',
skills: '/partials/skills',
jobs: '/partials/jobs',
Expand Down
110 changes: 109 additions & 1 deletion api/templates/partials/history.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@
<div class="space-y-6" x-data="{
mode: {{ mode|default('injection', true)|tojson|forceescape }},
maxTurns: {{ max_turns|default('10', true)|tojson|forceescape }},
compactionEnabled: {{ compaction_enabled|default('true', true)|tojson|forceescape }},
thresholdType: {{ compaction_threshold_type|default('percent', true)|tojson|forceescape }},
thresholdPercent: {{ compaction_threshold_percent|default('80', true)|tojson|forceescape }},
thresholdTokens: {{ compaction_threshold_tokens|default('150000', true)|tojson|forceescape }},
contextWindow: {{ compaction_context_window|default('200000', true)|tojson|forceescape }},
keepRecentTurns: {{ compaction_keep_recent_turns|default('4', true)|tojson|forceescape }},
result: '',
resultOk: false
resultOk: false,
cResult: '',
cResultOk: false
}">
<div class="card">
<h2 class="text-base mb-1">Conversation History</h2>
Expand Down Expand Up @@ -66,4 +74,104 @@ <h2 class="text-base mb-1">Conversation History</h2>
<div :class="resultOk ? 'alert-success' : 'alert-error'" class="mt-2" x-text="result"></div>
</template>
</div>

{# Compaction — only relevant in session mode #}
<div class="card">
<div class="flex items-center justify-between gap-3 mb-1">
<h2 class="text-base">Context compaction</h2>
<div class="flex items-center gap-2">
<label class="label mb-0">Enabled</label>
<input type="checkbox" x-model="compactionEnabled"
:checked="compactionEnabled === 'true' || compactionEnabled === true">
</div>
</div>
<p class="text-muted text-xs mb-4">
In <strong>session</strong> mode, when the conversation approaches the model's
context window, the oldest turns are summarized by a small model (configured in
the <strong>LLM</strong> tab) while recent turns are kept verbatim. The user is
notified with a system message when this happens. Has no effect in injection mode.
</p>

<div class="space-y-3" :class="(compactionEnabled === 'true' || compactionEnabled === true) ? '' : 'opacity-50'">
<div>
<label class="label">Trigger threshold</label>
<div class="flex gap-4 mt-1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="threshold_type" value="percent"
x-model="thresholdType" class="accent-primary">
<span class="text-sm">% of context window</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="threshold_type" value="tokens"
x-model="thresholdType" class="accent-primary">
<span class="text-sm">Absolute tokens</span>
</label>
</div>
</div>

<div x-show="thresholdType === 'percent'" x-transition class="flex gap-4 flex-wrap">
<div>
<label class="label">Percent</label>
<input type="number" class="input-sm" style="max-width:120px"
x-model="thresholdPercent" min="10" max="99">
<p class="text-muted text-xs mt-1">Compact at this % of the window.</p>
</div>
<div>
<label class="label">Context window (fallback)</label>
<input type="number" class="input-sm" style="max-width:160px"
x-model="contextWindow" min="8000" step="1000">
<p class="text-muted text-xs mt-1">Used when the model's window isn't known (e.g. 200000, 1000000).</p>
</div>
</div>

<div x-show="thresholdType === 'tokens'" x-transition>
<label class="label">Tokens</label>
<input type="number" class="input-sm" style="max-width:160px"
x-model="thresholdTokens" min="8000" step="1000">
<p class="text-muted text-xs mt-1">Compact once the context reaches this many tokens (e.g. 150000).</p>
</div>

<div>
<label class="label">Keep recent turns</label>
<input type="number" class="input-sm" style="max-width:120px"
x-model="keepRecentTurns" min="1" max="20">
<p class="text-muted text-xs mt-1">Most-recent user turns kept verbatim; everything older is summarized.</p>
</div>
</div>

<div class="flex items-center gap-2 mt-4">
<button class="btn-primary btn-sm"
@click="
const vals = {
'compaction.enabled': String(compactionEnabled === true || compactionEnabled === 'true'),
'compaction.threshold_type': thresholdType,
'compaction.threshold_percent': String(thresholdPercent),
'compaction.threshold_tokens': String(thresholdTokens),
'compaction.context_window': String(contextWindow),
'compaction.keep_recent_turns': String(keepRecentTurns)
};
fetch('/config', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (localStorage.getItem('admin_api_key') || '')
},
body: JSON.stringify({values: vals})
})
.then(r => { cResultOk = r.ok; return r.json(); })
.then(d => {
cResult = cResultOk ? 'Compaction settings saved' : (d.detail || 'Error');
if (cResultOk && window.showToast) { window.showToast('Compaction settings saved'); }
})
.catch(e => { cResultOk = false; cResult = e.message; })
">
Save
</button>
<span class="text-muted text-xs">Compaction model is set in the LLM tab.</span>
</div>

<template x-if="cResult">
<div :class="cResultOk ? 'alert-success' : 'alert-error'" class="mt-2" x-text="cResult"></div>
</template>
</div>
</div>
59 changes: 58 additions & 1 deletion api/templates/partials/llm.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
{{ 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 }}
{{ 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 }}
)">
<div class="card">
<h2 class="text-base mb-1">System Prompt Controls</h2>
Expand Down Expand Up @@ -415,6 +417,61 @@ <h2 class="text-base mb-1">Task Reflection</h2>
</template>
</div>

<div class="card">
<h2 class="text-base mb-1">History Compaction</h2>
<p class="text-muted text-xs mb-4">Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the <strong>History</strong> tab.</p>

<form class="space-y-4" @submit.prevent>
<div>
<label class="label">Provider</label>
<select class="input-sm" style="max-width:500px" x-model="compactionProvider" x-effect="$el.value = compactionProvider">
<template x-for="item in providerOptions" :key="'cp-' + item.value">
<option :value="item.value" :selected="item.value === compactionProvider" x-text="item.label"></option>
</template>
</select>
</div>
<div>
<label class="label">Model</label>
<input type="text" class="input-sm" style="max-width:500px" x-model="compactionModel"
list="dl-model-compaction" placeholder="Type or pick a model">
<datalist id="dl-model-compaction">
<template x-for="item in modelOptions(compactionProvider)" :key="'cp-m-' + item.value">
<option :value="item.value"></option>
</template>
</datalist>
<p class="text-muted text-xs mt-1">A small, fast model is recommended (summarization is cheap).</p>
</div>
</form>

<div class="flex items-center gap-2 mt-4">
<button class="btn-primary btn-sm"
@click="
fetch('/config', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (localStorage.getItem('admin_api_key') || '')
},
body: JSON.stringify({values: {
'compaction.provider': compactionProvider,
'compaction.model': compactionModel
}})
})
.then(r => { compactionResultOk = r.ok; return r.json(); })
.then(d => {
compactionResult = compactionResultOk ? 'Compaction model saved' : (d.detail || 'Error');
if (compactionResultOk && window.showToast) { window.showToast('Compaction model saved'); }
})
.catch(e => { compactionResultOk = false; compactionResult = e.message; })
">
Save compaction model
</button>
</div>
<template x-if="compactionResult">
<div :class="compactionResultOk ? 'alert-success' : 'alert-error'" class="mt-2" x-text="compactionResult"></div>
</template>
</div>

<div class="card">
<h2 class="text-base mb-1">Anthropic</h2>
<p class="text-muted text-xs mb-4">Configure the Anthropic API key and test the connection.</p>
Expand Down
Loading
Loading