@aion0/forge 0.9.18 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +4 -18
- package/app/api/agents/[id]/test/route.ts +4 -2
- package/app/api/agents/route.ts +26 -6
- package/app/api/memory/blocks/route.ts +56 -0
- package/app/api/monitor/route.ts +2 -0
- package/app/api/schedules/extract/route.ts +8 -6
- package/app/chat/page.tsx +189 -2
- package/bin/forge-server.mjs +3 -2
- package/components/MonitorPanel.tsx +2 -0
- package/components/SettingsModal.tsx +87 -68
- package/lib/agents/claude-adapter.ts +6 -1
- package/lib/agents/generic-adapter.ts +2 -1
- package/lib/agents/index.ts +23 -19
- package/lib/agents/migrate.ts +159 -0
- package/lib/chat/agent-loop.ts +53 -24
- package/lib/chat/build-memory-context.ts +91 -0
- package/lib/chat/llm/openai.ts +4 -1
- package/lib/chat/local-memory.ts +22 -5
- package/lib/chat/session-store.ts +49 -0
- package/lib/chat-standalone.ts +6 -0
- package/lib/init.ts +25 -0
- package/lib/memory/compress-messages.ts +65 -0
- package/lib/memory/keys.ts +82 -0
- package/lib/memory/temper-summary.ts +485 -0
- package/lib/memory/token-estimate.ts +28 -0
- package/lib/memory-standalone.ts +108 -0
- package/lib/settings.ts +84 -22
- package/lib/workspace/skill-installer.ts +26 -6
- package/package.json +1 -1
- package/scripts/test-agents-migrate.ts +149 -0
- package/scripts/test-memory-local.ts +139 -0
- package/scripts/test-memory-upsert.ts +106 -0
|
@@ -882,14 +882,16 @@ interface AgentEntry {
|
|
|
882
882
|
backendType?: string;
|
|
883
883
|
}
|
|
884
884
|
|
|
885
|
-
function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
|
|
885
|
+
function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp }: {
|
|
886
886
|
id: string; cfg: any; inputClass: string;
|
|
887
887
|
onUpdate: (cfg: any) => void; onDelete: () => void;
|
|
888
|
+
isApi?: boolean;
|
|
888
889
|
}) {
|
|
889
890
|
const [expanded, setExpanded] = useState(false);
|
|
890
891
|
const [testing, setTesting] = useState(false);
|
|
891
892
|
const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
|
|
892
|
-
|
|
893
|
+
// Post-migration apiProfile entries no longer carry `type: 'api'`. Caller passes isApi explicitly.
|
|
894
|
+
const isApi = isApiProp ?? (cfg.type === 'api');
|
|
893
895
|
|
|
894
896
|
async function runTest() {
|
|
895
897
|
setTesting(true);
|
|
@@ -1379,16 +1381,16 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1379
1381
|
const agentsCfg: Record<string, any> = { ...(prev.agents || {}) };
|
|
1380
1382
|
for (const a of updated) {
|
|
1381
1383
|
const existing = agentsCfg[a.id] || {};
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
// wrote it via setSettings directly (line ~1540).
|
|
1384
|
+
// Persist `tool` (which CLI binary to wrap) — listAgents reads this
|
|
1385
|
+
// post-migration to clone the matching builtin's adapter. cliType in
|
|
1386
|
+
// the AgentEntry is the form's selector value; map it to tool.
|
|
1387
|
+
const cliType = (a as any).cliType || existing.cliType;
|
|
1388
|
+
const tool = existing.tool
|
|
1389
|
+
|| (cliType === 'claude-code' ? 'claude' : cliType === 'codex' ? 'codex' : cliType === 'aider' ? 'aider' : undefined)
|
|
1390
|
+
|| (['claude', 'codex', 'aider', 'opencode'].includes(a.id) ? a.id : undefined);
|
|
1390
1391
|
agentsCfg[a.id] = {
|
|
1391
|
-
...existing,
|
|
1392
|
+
...existing,
|
|
1393
|
+
...(tool ? { tool } : {}),
|
|
1392
1394
|
name: a.name,
|
|
1393
1395
|
path: a.path,
|
|
1394
1396
|
enabled: a.enabled,
|
|
@@ -1447,10 +1449,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1447
1449
|
// settings.agents is one map keyed by id — agents + CLI profiles + API
|
|
1448
1450
|
// profiles all coexist. A duplicate id silently overwrites whichever
|
|
1449
1451
|
// entry was there first. Block the add and tell the user why.
|
|
1450
|
-
if (agents.some(a => a.id === newAgent.id) || settings.agents?.[newAgent.id]) {
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
alert(`id "${newAgent.id}" is already in use by a ${kind}. Pick a different id.`);
|
|
1452
|
+
if (agents.some(a => a.id === newAgent.id) || settings.agents?.[newAgent.id] || (settings as any).apiProfiles?.[newAgent.id]) {
|
|
1453
|
+
const kind = (settings as any).apiProfiles?.[newAgent.id] ? 'API profile' : 'agent';
|
|
1454
|
+
alert(`id "${newAgent.id}" is already in use by an ${kind}. Pick a different id.`);
|
|
1454
1455
|
return;
|
|
1455
1456
|
}
|
|
1456
1457
|
const entry: AgentEntry = {
|
|
@@ -1552,8 +1553,12 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1552
1553
|
</div>
|
|
1553
1554
|
<div className="w-36">
|
|
1554
1555
|
<label className="text-[9px] text-[var(--text-secondary)]">CLI Type</label>
|
|
1555
|
-
<select value={(settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic')}
|
|
1556
|
-
onChange={e =>
|
|
1556
|
+
<select value={(settings.agents?.[a.id] as any)?.tool || (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic')}
|
|
1557
|
+
onChange={e => {
|
|
1558
|
+
const v = e.target.value;
|
|
1559
|
+
const tool = v === 'claude-code' ? 'claude' : v === 'generic' ? undefined : v;
|
|
1560
|
+
setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), ...(tool ? { tool } : {}), cliType: v } } });
|
|
1561
|
+
}}
|
|
1557
1562
|
className={inputClass}>
|
|
1558
1563
|
<option value="claude-code">Claude Code</option>
|
|
1559
1564
|
<option value="codex">Codex</option>
|
|
@@ -1647,24 +1652,58 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1647
1652
|
Requires terminal environment (TTY)
|
|
1648
1653
|
<span className="text-[8px]">— enable for agents that need a terminal to run (e.g. Codex)</span>
|
|
1649
1654
|
</label>
|
|
1655
|
+
|
|
1656
|
+
{/* Env vars — was the CLI Profile feature, now lives on the agent itself */}
|
|
1657
|
+
{(() => {
|
|
1658
|
+
const envObj: Record<string, string> = ((settings.agents?.[a.id] as any)?.env) || {};
|
|
1659
|
+
const envStr = Object.entries(envObj).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
1660
|
+
const tool = (settings.agents?.[a.id] as any)?.tool || (settings.agents?.[a.id] as any)?.cliType;
|
|
1661
|
+
const TEMPLATES: Record<string, string> = {
|
|
1662
|
+
claude: 'ANTHROPIC_AUTH_TOKEN=\nANTHROPIC_BASE_URL=\nANTHROPIC_SMALL_FAST_MODEL=\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=true\nDISABLE_TELEMETRY=true\nDISABLE_ERROR_REPORTING=true\nDISABLE_AUTOUPDATER=true\nDISABLE_NON_ESSENTIAL_MODEL_CALLS=true',
|
|
1663
|
+
'claude-code': 'ANTHROPIC_AUTH_TOKEN=\nANTHROPIC_BASE_URL=\nANTHROPIC_SMALL_FAST_MODEL=\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=true\nDISABLE_TELEMETRY=true\nDISABLE_ERROR_REPORTING=true\nDISABLE_AUTOUPDATER=true\nDISABLE_NON_ESSENTIAL_MODEL_CALLS=true',
|
|
1664
|
+
codex: 'OPENAI_API_KEY=\nOPENAI_BASE_URL=',
|
|
1665
|
+
aider: 'ANTHROPIC_API_KEY=\nOPENAI_API_KEY=',
|
|
1666
|
+
};
|
|
1667
|
+
const tpl = tool ? TEMPLATES[tool] : '';
|
|
1668
|
+
return (
|
|
1669
|
+
<div>
|
|
1670
|
+
<div className="flex items-center gap-2">
|
|
1671
|
+
<label className="text-[9px] text-[var(--text-secondary)]">Environment Variables <span className="text-[8px]">(KEY=VALUE per line, applied to this agent's spawned process)</span></label>
|
|
1672
|
+
{tpl && (
|
|
1673
|
+
<button onClick={() => {
|
|
1674
|
+
const merged: Record<string, string> = {};
|
|
1675
|
+
for (const line of tpl.split('\n')) {
|
|
1676
|
+
const eq = line.indexOf('=');
|
|
1677
|
+
if (eq > 0) merged[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
1678
|
+
}
|
|
1679
|
+
Object.assign(merged, envObj); // existing values win
|
|
1680
|
+
setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), env: merged } } });
|
|
1681
|
+
}} className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20">
|
|
1682
|
+
Fill {tool} template
|
|
1683
|
+
</button>
|
|
1684
|
+
)}
|
|
1685
|
+
</div>
|
|
1686
|
+
<textarea
|
|
1687
|
+
value={envStr}
|
|
1688
|
+
onChange={e => {
|
|
1689
|
+
const next: Record<string, string> = {};
|
|
1690
|
+
for (const line of e.target.value.split('\n')) {
|
|
1691
|
+
const eq = line.indexOf('=');
|
|
1692
|
+
if (eq > 0) next[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
1693
|
+
}
|
|
1694
|
+
setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), env: Object.keys(next).length > 0 ? next : undefined } } });
|
|
1695
|
+
}}
|
|
1696
|
+
rows={4}
|
|
1697
|
+
placeholder="ANTHROPIC_AUTH_TOKEN=sk-... ANTHROPIC_BASE_URL=https://..."
|
|
1698
|
+
className={inputClass + ' resize-none font-mono'}
|
|
1699
|
+
/>
|
|
1700
|
+
</div>
|
|
1701
|
+
);
|
|
1702
|
+
})()}
|
|
1703
|
+
|
|
1650
1704
|
{a.id !== 'claude' && (
|
|
1651
1705
|
<button onClick={() => removeAgent(a.id)} className="text-[9px] text-red-400 hover:underline">Remove Agent</button>
|
|
1652
1706
|
)}
|
|
1653
|
-
|
|
1654
|
-
{/* Profile selector */}
|
|
1655
|
-
<div>
|
|
1656
|
-
<label className="text-[9px] text-[var(--text-secondary)]">Profile <span className="text-[8px]">— select to override model, env vars, API endpoint</span></label>
|
|
1657
|
-
<select
|
|
1658
|
-
value={(settings.agents?.[a.id] as any)?.profile || ''}
|
|
1659
|
-
onChange={e => setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), profile: e.target.value || undefined } } })}
|
|
1660
|
-
className={inputClass}
|
|
1661
|
-
>
|
|
1662
|
-
<option value="">Default (no profile)</option>
|
|
1663
|
-
{Object.entries(settings.agents || {}).filter(([, cfg]: [string, any]) => cfg.cliType || cfg.base || cfg.type === 'profile' || cfg.type === 'api').map(([pid, cfg]: [string, any]) => (
|
|
1664
|
-
<option key={pid} value={pid}>{cfg.name || pid}{cfg.model ? ` (${cfg.model})` : ''}</option>
|
|
1665
|
-
))}
|
|
1666
|
-
</select>
|
|
1667
|
-
</div>
|
|
1668
1707
|
</div>
|
|
1669
1708
|
)}
|
|
1670
1709
|
</div>
|
|
@@ -1738,54 +1777,34 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1738
1777
|
</div>
|
|
1739
1778
|
)}
|
|
1740
1779
|
|
|
1741
|
-
{/* ── Profiles Section ── */}
|
|
1780
|
+
{/* ── API Profiles Section ── */}
|
|
1742
1781
|
<div className="mt-4 pt-3 border-t border-[var(--border)]">
|
|
1743
1782
|
<div className="flex items-center gap-2 mb-2">
|
|
1744
|
-
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Profiles</label>
|
|
1745
|
-
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
1783
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">API Profiles</label>
|
|
1784
|
+
<span className="text-[8px] text-[var(--text-secondary)]">HTTP endpoints used by chat / web / Telegram (separate from CLI agents above)</span>
|
|
1746
1785
|
</div>
|
|
1747
1786
|
|
|
1748
|
-
{
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
<ProfileRow key={id} id={id} cfg={cfg} inputClass={inputClass}
|
|
1752
|
-
onUpdate={(updated) => setSettings({ ...settings, agents: { ...settings.agents, [id]: updated } })}
|
|
1787
|
+
{Object.entries((settings as any).apiProfiles || {}).map(([id, cfg]: [string, any]) => (
|
|
1788
|
+
<ProfileRow key={id} id={id} cfg={cfg} inputClass={inputClass} isApi
|
|
1789
|
+
onUpdate={(updated) => setSettings({ ...settings, apiProfiles: { ...((settings as any).apiProfiles || {}), [id]: updated } })}
|
|
1753
1790
|
onDelete={() => {
|
|
1754
|
-
const updated = { ...settings.
|
|
1791
|
+
const updated = { ...((settings as any).apiProfiles || {}) };
|
|
1755
1792
|
delete updated[id];
|
|
1756
|
-
setSettings({ ...settings,
|
|
1793
|
+
setSettings({ ...settings, apiProfiles: updated });
|
|
1757
1794
|
}}
|
|
1758
1795
|
/>
|
|
1759
1796
|
))}
|
|
1760
1797
|
|
|
1761
1798
|
<div className="flex gap-2 mt-1">
|
|
1762
|
-
{/* Functional setSettings —
|
|
1763
|
-
`settings` snapshot, which clobbers any agent that was saved
|
|
1764
|
-
between this component's last render and the Add click
|
|
1765
|
-
(debouncedSave for the Agents section fires 1s later, so the
|
|
1766
|
-
window is real). Also: settings.agents is a single map keyed
|
|
1767
|
-
by id — agents + CLI profiles + API profiles share it — so
|
|
1768
|
-
reusing an id silently overwrites. Block dups with an alert. */}
|
|
1769
|
-
<AddProfileForm type="cli" baseAgents={agents.filter(a => !a.isProfile && a.detected)} onAdd={(id, cfg) => {
|
|
1770
|
-
setSettings((prev: any) => {
|
|
1771
|
-
if (prev.agents?.[id]) {
|
|
1772
|
-
const existing = prev.agents[id] as any;
|
|
1773
|
-
const kind = existing.type === 'api' ? 'API profile' : existing.cliType || existing.base ? 'CLI profile' : 'agent';
|
|
1774
|
-
alert(`id "${id}" is already in use by a ${kind}. Pick a different id.`);
|
|
1775
|
-
return prev;
|
|
1776
|
-
}
|
|
1777
|
-
return { ...prev, agents: { ...(prev.agents || {}), [id]: cfg } };
|
|
1778
|
-
});
|
|
1779
|
-
}} />
|
|
1799
|
+
{/* Functional setSettings — captures stale snapshot otherwise (debouncedSave window is real). */}
|
|
1780
1800
|
<AddProfileForm type="api" baseAgents={[]} onAdd={(id, cfg) => {
|
|
1781
1801
|
setSettings((prev: any) => {
|
|
1782
|
-
if (prev.
|
|
1783
|
-
|
|
1784
|
-
const kind = existing.type === 'api' ? 'API profile' : existing.cliType || existing.base ? 'CLI profile' : 'agent';
|
|
1785
|
-
alert(`id "${id}" is already in use by a ${kind}. Pick a different id.`);
|
|
1802
|
+
if ((prev.apiProfiles || {})[id]) {
|
|
1803
|
+
alert(`API profile id "${id}" already exists. Pick a different id.`);
|
|
1786
1804
|
return prev;
|
|
1787
1805
|
}
|
|
1788
|
-
|
|
1806
|
+
const { type, ...rest } = cfg; // drop legacy type:'api' field; not in new shape
|
|
1807
|
+
return { ...prev, apiProfiles: { ...(prev.apiProfiles || {}), [id]: { ...rest, enabled: true } } };
|
|
1789
1808
|
});
|
|
1790
1809
|
}} />
|
|
1791
1810
|
</div>
|
|
@@ -1953,8 +1972,8 @@ function TemperSection({ settings, setSettings, secretStatus, setEditingSecret }
|
|
|
1953
1972
|
// Telegram /chat) picks the API profile listed here. The user can override
|
|
1954
1973
|
// per-session by setting Session.provider to a profile id.
|
|
1955
1974
|
function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1956
|
-
const apiProfiles = Object.entries(settings.
|
|
1957
|
-
.filter(([, a]: [string, any]) => a && a.
|
|
1975
|
+
const apiProfiles = Object.entries((settings as any).apiProfiles || {})
|
|
1976
|
+
.filter(([, a]: [string, any]) => a && a.enabled !== false);
|
|
1958
1977
|
|
|
1959
1978
|
return (
|
|
1960
1979
|
<div className="mt-4 pt-3 border-t border-[var(--border)]">
|
|
@@ -1974,7 +1993,7 @@ function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings
|
|
|
1974
1993
|
</div>
|
|
1975
1994
|
{apiProfiles.length === 0 ? (
|
|
1976
1995
|
<div className="text-[9px] text-[var(--text-secondary)] mt-1.5">
|
|
1977
|
-
No API profile yet. Add one in the Profiles section above
|
|
1996
|
+
No API profile yet. Add one in the API Profiles section above. LiteLLM proxies are supported via the Base URL field.
|
|
1978
1997
|
</div>
|
|
1979
1998
|
) : (
|
|
1980
1999
|
<div className="text-[9px] text-[var(--text-secondary)] mt-1.5">
|
|
@@ -72,7 +72,12 @@ export function createClaudeAdapter(config: AgentConfig): AgentAdapter {
|
|
|
72
72
|
|
|
73
73
|
args.push(opts.prompt);
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// Forward agent-level env (e.g. ANTHROPIC_AUTH_TOKEN/BASE_URL on a
|
|
76
|
+
// user-named CLI agent that wraps claude). task-manager merges this
|
|
77
|
+
// into the child env, letting one agent point claude at a custom
|
|
78
|
+
// endpoint without touching the user's global ~/.claude config.
|
|
79
|
+
const env = (config as any).env as Record<string, string> | undefined;
|
|
80
|
+
return { cmd: resolved.cmd, args, ...(env && Object.keys(env).length ? { env } : {}) };
|
|
76
81
|
},
|
|
77
82
|
|
|
78
83
|
buildTerminalCommand(opts) {
|
|
@@ -31,7 +31,8 @@ export function createGenericAdapter(config: AgentConfig): AgentAdapter {
|
|
|
31
31
|
args.push(...opts.extraFlags);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
const env = (config as any).env as Record<string, string> | undefined;
|
|
35
|
+
return { cmd: config.path, args, ...(env && Object.keys(env).length ? { env } : {}) };
|
|
35
36
|
},
|
|
36
37
|
|
|
37
38
|
buildTerminalCommand(opts) {
|
package/lib/agents/index.ts
CHANGED
|
@@ -73,35 +73,39 @@ export function listAgents(): AgentConfig[] {
|
|
|
73
73
|
agents.push({ ...aider, enabled: aiderConfig?.enabled !== false, detected: true, skipPermissionsFlag: aiderConfig?.skipPermissionsFlag || '--yes', cliType: 'aider' } as any);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
//
|
|
76
|
+
// User-named CLI agents from settings (post-migration shape: tool field)
|
|
77
77
|
if (settings.agents) {
|
|
78
78
|
for (const [id, cfg] of Object.entries(settings.agents)) {
|
|
79
|
-
if (['claude', 'codex', 'aider'].includes(id)) continue;
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// this path at all — it reads settings.agents directly.
|
|
87
|
-
if (cfg.type === 'api') {
|
|
79
|
+
if (['claude', 'codex', 'aider'].includes(id)) continue; // builtins handled above
|
|
80
|
+
|
|
81
|
+
// Post-migration: tool field identifies which CLI to wrap.
|
|
82
|
+
// Find the matching builtin we already detected and clone its
|
|
83
|
+
// adapter shape, overriding model / env / etc. from this entry.
|
|
84
|
+
if (cfg.tool) {
|
|
85
|
+
const baseAgent = agents.find(a => a.id === cfg.tool);
|
|
88
86
|
agents.push({
|
|
87
|
+
...(baseAgent || { type: 'generic' as const, capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false } }),
|
|
89
88
|
id,
|
|
90
89
|
name: cfg.name || id,
|
|
91
|
-
path: '',
|
|
90
|
+
path: baseAgent?.path || '',
|
|
92
91
|
enabled: cfg.enabled !== false,
|
|
93
|
-
type: 'generic' as const,
|
|
94
|
-
capabilities: { supportsResume: false, supportsStreamJson: false, supportsModel: false, supportsSkipPermissions: false, hasSessionFiles: false, requiresTTY: false },
|
|
95
92
|
isProfile: true,
|
|
96
|
-
backendType: '
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
backendType: 'cli',
|
|
94
|
+
model: cfg.model || cfg.models?.task,
|
|
95
|
+
skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
|
|
96
|
+
env: cfg.env,
|
|
97
|
+
cliType: (baseAgent as any)?.cliType || 'generic',
|
|
100
98
|
} as any);
|
|
101
99
|
continue;
|
|
102
100
|
}
|
|
103
101
|
|
|
104
|
-
//
|
|
102
|
+
// === Legacy fallback (pre-migration / un-migrated) ===
|
|
103
|
+
// type: 'api' — already moved to settings.apiProfiles by migration,
|
|
104
|
+
// but keep this branch for the migration window in case someone hits
|
|
105
|
+
// it before migrateAgentsFlatten runs. After 1-2 versions, remove.
|
|
106
|
+
if (cfg.type === 'api') continue; // silently skip — apiProfiles is canonical now
|
|
107
|
+
|
|
108
|
+
// CLI profile via base (legacy) — same path as tool branch above
|
|
105
109
|
if (cfg.base) {
|
|
106
110
|
const baseAgent = agents.find(a => a.id === cfg.base);
|
|
107
111
|
agents.push({
|
|
@@ -121,7 +125,7 @@ export function listAgents(): AgentConfig[] {
|
|
|
121
125
|
continue;
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
// Custom agent
|
|
128
|
+
// Custom agent — explicit path
|
|
125
129
|
if (!cfg.path) continue;
|
|
126
130
|
const flags = cfg.taskFlags ? cfg.taskFlags.split(/\s+/).filter(Boolean) : cfg.flags;
|
|
127
131
|
const detected = detectAgent(id, cfg.name || id, cfg.path, flags);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time migration: agents flat dict → agents (CLI) + apiProfiles (API).
|
|
3
|
+
*
|
|
4
|
+
* Pre-v0.9.20 `settings.agents` held both CLI agent configs (claude /
|
|
5
|
+
* codex / aider / user-named "CLI profiles" with `base:`) AND HTTP API
|
|
6
|
+
* profiles (`type: 'api'`). Two distinct concepts crammed into one
|
|
7
|
+
* dict with field overlap → the new-install pipeline-uses-API-model
|
|
8
|
+
* bug (see todo_pipeline_model_resolution memory).
|
|
9
|
+
*
|
|
10
|
+
* Migration walks settings.agents once on startup:
|
|
11
|
+
* - `type: 'api'` entries → moved to settings.apiProfiles
|
|
12
|
+
* - CLI entries: best-effort flatten `base` / `cliType` → `tool`
|
|
13
|
+
* (if neither can be inferred, entry stays put; UI will skip it
|
|
14
|
+
* and the user re-adds manually)
|
|
15
|
+
* - defaultAgent / chatAgent get validated against the new dicts and
|
|
16
|
+
* downgraded to safe defaults if they point to the wrong kind
|
|
17
|
+
*
|
|
18
|
+
* Before writing anything we drop a backup at
|
|
19
|
+
* `<dataDir>/settings.backup.agents-<ts>.yaml` containing just the
|
|
20
|
+
* agent-related slices of settings.yaml, so a botched migration can be
|
|
21
|
+
* rolled back by hand.
|
|
22
|
+
*
|
|
23
|
+
* Idempotent: once the deprecated fields are gone, the next call is a
|
|
24
|
+
* no-op (returns false, no save).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { writeFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import YAML from 'yaml';
|
|
30
|
+
import { getDataDir } from '../dirs';
|
|
31
|
+
import type { Settings } from '../settings';
|
|
32
|
+
|
|
33
|
+
const BUILTIN_TOOLS = new Set(['claude', 'codex', 'aider', 'opencode']);
|
|
34
|
+
|
|
35
|
+
/** Returns true if anything was mutated (caller should save). */
|
|
36
|
+
export function migrateAgentsFlatten(settings: Settings): boolean {
|
|
37
|
+
const needsMigration = detectsLegacyShape(settings);
|
|
38
|
+
if (!needsMigration) return false;
|
|
39
|
+
|
|
40
|
+
writeBackup(settings);
|
|
41
|
+
|
|
42
|
+
let mutated = false;
|
|
43
|
+
(settings as any).apiProfiles ??= {};
|
|
44
|
+
|
|
45
|
+
for (const [id, raw] of Object.entries(settings.agents ?? {}) as [string, any][]) {
|
|
46
|
+
// === 必须 — type='api' 搬到 apiProfiles ===
|
|
47
|
+
if (raw.type === 'api') {
|
|
48
|
+
(settings as any).apiProfiles[id] = {
|
|
49
|
+
name: raw.name ?? id,
|
|
50
|
+
enabled: raw.enabled !== false,
|
|
51
|
+
provider: mapProvider(raw.provider),
|
|
52
|
+
model: raw.model ?? '',
|
|
53
|
+
apiKey: raw.apiKey ?? '',
|
|
54
|
+
baseUrl: raw.baseUrl ?? '',
|
|
55
|
+
};
|
|
56
|
+
delete settings.agents[id];
|
|
57
|
+
mutated = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// === Best-effort — 推断 tool 字段 ===
|
|
62
|
+
if (!raw.tool) {
|
|
63
|
+
if (raw.base && BUILTIN_TOOLS.has(raw.base)) raw.tool = raw.base;
|
|
64
|
+
else if (raw.cliType === 'claude-code') raw.tool = 'claude';
|
|
65
|
+
else if (raw.cliType && BUILTIN_TOOLS.has(raw.cliType)) raw.tool = raw.cliType;
|
|
66
|
+
else if (BUILTIN_TOOLS.has(id)) raw.tool = id;
|
|
67
|
+
if (raw.tool) mutated = true;
|
|
68
|
+
// 推断不出 → 留原样,listAgents 会跳过,用户接受手动重加
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 清掉已过期的字段(只有迁移产生过 tool 才删 base/cliType,避免无 tool 的孤儿丢标识)
|
|
72
|
+
if (raw.tool) {
|
|
73
|
+
if (raw.base !== undefined) { delete raw.base; mutated = true; }
|
|
74
|
+
if (raw.cliType !== undefined) { delete raw.cliType; mutated = true; }
|
|
75
|
+
}
|
|
76
|
+
if (raw.type !== undefined) { delete raw.type; mutated = true; }
|
|
77
|
+
|
|
78
|
+
// 老嵌套 models.task → 扁平 model(如果 model 字段空)
|
|
79
|
+
if (!raw.model && raw.models?.task && raw.models.task !== 'default') {
|
|
80
|
+
raw.model = raw.models.task;
|
|
81
|
+
mutated = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// === 兜底校验 defaultAgent / chatAgent ===
|
|
86
|
+
if (settings.defaultAgent && !settings.agents?.[settings.defaultAgent]) {
|
|
87
|
+
const wasApi = (settings as any).apiProfiles[settings.defaultAgent];
|
|
88
|
+
console.warn(
|
|
89
|
+
`[migrate-agents] defaultAgent="${settings.defaultAgent}" ` +
|
|
90
|
+
(wasApi ? 'is an API profile, not allowed for tasks. ' : 'not found. ') +
|
|
91
|
+
`Falling back to 'claude'.`
|
|
92
|
+
);
|
|
93
|
+
settings.defaultAgent = 'claude';
|
|
94
|
+
mutated = true;
|
|
95
|
+
}
|
|
96
|
+
if (settings.chatAgent && !(settings as any).apiProfiles[settings.chatAgent]) {
|
|
97
|
+
const wasCli = settings.agents?.[settings.chatAgent];
|
|
98
|
+
console.warn(
|
|
99
|
+
`[migrate-agents] chatAgent="${settings.chatAgent}" ` +
|
|
100
|
+
(wasCli ? 'is a CLI agent, not allowed for chat. ' : 'not found. ') +
|
|
101
|
+
`Falling back.`
|
|
102
|
+
);
|
|
103
|
+
const firstApi = Object.entries((settings as any).apiProfiles)
|
|
104
|
+
.find(([, p]: any) => p.enabled)?.[0];
|
|
105
|
+
settings.chatAgent = firstApi ?? '';
|
|
106
|
+
mutated = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (mutated) {
|
|
110
|
+
console.log('[migrate-agents] migrated settings.agents → agents + apiProfiles');
|
|
111
|
+
}
|
|
112
|
+
return mutated;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function detectsLegacyShape(settings: Settings): boolean {
|
|
116
|
+
for (const cfg of Object.values(settings.agents ?? {}) as any[]) {
|
|
117
|
+
if (cfg.type === 'api') return true;
|
|
118
|
+
if (cfg.base !== undefined) return true;
|
|
119
|
+
if (cfg.cliType !== undefined) return true;
|
|
120
|
+
// 没有 tool + 是 builtin id → 也算需要迁移(补 tool 字段)
|
|
121
|
+
}
|
|
122
|
+
// 内置 agent 没有 tool 字段也需要补
|
|
123
|
+
for (const [id, cfg] of Object.entries(settings.agents ?? {}) as [string, any][]) {
|
|
124
|
+
if (!cfg.tool && BUILTIN_TOOLS.has(id)) return true;
|
|
125
|
+
}
|
|
126
|
+
// defaultAgent 指向不存在的 entry 也需要兜底
|
|
127
|
+
if (settings.defaultAgent && !settings.agents?.[settings.defaultAgent]) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeBackup(settings: Settings): void {
|
|
134
|
+
try {
|
|
135
|
+
const ts = Date.now();
|
|
136
|
+
const backupPath = join(getDataDir(), `settings.backup.agents-${ts}.yaml`);
|
|
137
|
+
const slice = {
|
|
138
|
+
_backup_note: 'Pre-migration snapshot of agent-related settings. Safe to delete after verifying migration worked.',
|
|
139
|
+
_backup_ts: new Date(ts).toISOString(),
|
|
140
|
+
agents: settings.agents,
|
|
141
|
+
defaultAgent: settings.defaultAgent,
|
|
142
|
+
chatAgent: settings.chatAgent,
|
|
143
|
+
telegramAgent: settings.telegramAgent,
|
|
144
|
+
docsAgent: settings.docsAgent,
|
|
145
|
+
telegramModel: settings.telegramModel,
|
|
146
|
+
taskModel: settings.taskModel,
|
|
147
|
+
pipelineModel: settings.pipelineModel,
|
|
148
|
+
};
|
|
149
|
+
writeFileSync(backupPath, YAML.stringify(slice), 'utf-8');
|
|
150
|
+
console.log(`[migrate-agents] backup → ${backupPath}`);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.warn('[migrate-agents] backup write failed (continuing anyway):', err instanceof Error ? err.message : err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mapProvider(p?: string): 'anthropic' | 'openai-compatible' {
|
|
157
|
+
if (!p) return 'openai-compatible';
|
|
158
|
+
return /anthropic/i.test(p) ? 'anthropic' : 'openai-compatible';
|
|
159
|
+
}
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -16,16 +16,17 @@ import { loadSettings } from '../settings';
|
|
|
16
16
|
import {
|
|
17
17
|
appendMessage,
|
|
18
18
|
getSession,
|
|
19
|
-
|
|
19
|
+
listMessagesCapped,
|
|
20
20
|
} from './session-store';
|
|
21
21
|
import {
|
|
22
22
|
dispatchTool,
|
|
23
23
|
BUILTIN_TOOL_DEFS,
|
|
24
24
|
type BuiltinHandler,
|
|
25
25
|
} from './tool-dispatcher';
|
|
26
|
-
import { renderMemoryContext } from './temper';
|
|
27
26
|
import { getMemoryStore } from './memory-store';
|
|
27
|
+
import { buildMemoryContext } from './build-memory-context';
|
|
28
28
|
import { buildMemoryTools } from './memory-tools';
|
|
29
|
+
import { estimateTokens } from '../memory/token-estimate';
|
|
29
30
|
import {
|
|
30
31
|
listInstalledConnectors,
|
|
31
32
|
getConnector,
|
|
@@ -41,6 +42,28 @@ import type {
|
|
|
41
42
|
|
|
42
43
|
const MAX_ITERATIONS = 6;
|
|
43
44
|
const MAX_TOKENS = 16000;
|
|
45
|
+
// Working-window budgets for the LLM history. Capped by message count
|
|
46
|
+
// AND by token estimate (whichever hits first), see design §8. Older
|
|
47
|
+
// raw is summarized by the memory-standalone Temper Summary sub-task
|
|
48
|
+
// and recalled via buildMemoryContext as compact blocks instead.
|
|
49
|
+
const HISTORY_MSG_BUDGET = 60;
|
|
50
|
+
const HISTORY_TOKEN_BUDGET = 8000;
|
|
51
|
+
|
|
52
|
+
// After clipping to last N, the first kept message may be a tool_result
|
|
53
|
+
// whose tool_use was cut. Anthropic/OpenAI both reject that, so drop
|
|
54
|
+
// leading tool_result-bearing user messages until the slice starts clean.
|
|
55
|
+
function trimOrphanToolResults(history: Message[]): Message[] {
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < history.length) {
|
|
58
|
+
const m = history[i];
|
|
59
|
+
const hasToolResult = m.role === 'user'
|
|
60
|
+
&& Array.isArray(m.blocks)
|
|
61
|
+
&& m.blocks.some((b) => (b as any).type === 'tool_result');
|
|
62
|
+
if (!hasToolResult) break;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
return i === 0 ? history : history.slice(i);
|
|
66
|
+
}
|
|
44
67
|
|
|
45
68
|
export interface AgentEvent {
|
|
46
69
|
type:
|
|
@@ -59,7 +82,7 @@ export type AgentCallbacks = {
|
|
|
59
82
|
onEvent: (event: AgentEvent) => void;
|
|
60
83
|
};
|
|
61
84
|
|
|
62
|
-
interface ProviderResolution {
|
|
85
|
+
export interface ProviderResolution {
|
|
63
86
|
name: string;
|
|
64
87
|
type: 'anthropic' | 'openai';
|
|
65
88
|
apiKey: string;
|
|
@@ -126,38 +149,36 @@ export function pickApiKey(profile: { apiKey?: string; env?: Record<string, stri
|
|
|
126
149
|
return env.OPENAI_API_KEY || '';
|
|
127
150
|
}
|
|
128
151
|
|
|
129
|
-
function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
|
|
152
|
+
export function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
|
|
130
153
|
const settings = loadSettings();
|
|
131
|
-
const
|
|
154
|
+
const profiles = settings.apiProfiles || {};
|
|
132
155
|
|
|
133
|
-
// Candidate API profiles:
|
|
134
|
-
const candidates = Object.entries(
|
|
135
|
-
if (!
|
|
136
|
-
|
|
137
|
-
return pickApiKey(a, adapter).length > 0;
|
|
156
|
+
// Candidate API profiles: enabled + has apiKey.
|
|
157
|
+
const candidates = Object.entries(profiles).filter(([_, p]) => {
|
|
158
|
+
if (!p || p.enabled === false) return false;
|
|
159
|
+
return (p.apiKey || '').length > 0;
|
|
138
160
|
});
|
|
139
161
|
if (candidates.length === 0) {
|
|
140
|
-
return { error: 'No API
|
|
162
|
+
return { error: 'No API profile with an API key. Add one under Settings → API Profiles and pick it as the chat profile.' };
|
|
141
163
|
}
|
|
142
164
|
|
|
143
|
-
// Preferred: session.provider (an
|
|
165
|
+
// Preferred: session.provider (an apiProfile id), then settings.chatAgent, else first candidate.
|
|
144
166
|
const preferredId = sessionProvider || settings.chatAgent || candidates[0]![0];
|
|
145
|
-
const profile =
|
|
146
|
-
?
|
|
167
|
+
const profile = profiles[preferredId] && profiles[preferredId].enabled !== false
|
|
168
|
+
? profiles[preferredId]
|
|
147
169
|
: candidates[0]![1];
|
|
148
|
-
const name =
|
|
170
|
+
const name = (profiles[preferredId] && profiles[preferredId].enabled !== false) ? preferredId : candidates[0]![0];
|
|
149
171
|
|
|
150
|
-
const adapter =
|
|
151
|
-
|
|
152
|
-
if (!apiKey) return { error: `Agent profile "${name}" has no apiKey (direct or via env).` };
|
|
172
|
+
const adapter: 'anthropic' | 'openai' = profile.provider === 'anthropic' ? 'anthropic' : 'openai';
|
|
173
|
+
if (!profile.apiKey) return { error: `API profile "${name}" has no apiKey.` };
|
|
153
174
|
|
|
154
175
|
const model = sessionModel || profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');
|
|
155
176
|
|
|
156
177
|
return {
|
|
157
178
|
name,
|
|
158
179
|
type: adapter,
|
|
159
|
-
apiKey,
|
|
160
|
-
baseUrl:
|
|
180
|
+
apiKey: profile.apiKey,
|
|
181
|
+
baseUrl: profile.baseUrl || defaultBaseUrl(profile.provider),
|
|
161
182
|
model,
|
|
162
183
|
};
|
|
163
184
|
}
|
|
@@ -372,18 +393,24 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
372
393
|
for (const t of memTools) memHandlers[t.def.name] = t.handle;
|
|
373
394
|
|
|
374
395
|
if (memStore.enabled) {
|
|
375
|
-
|
|
396
|
+
// Inspector strip (memory_status event) wants the full inventory —
|
|
397
|
+
// keep its own listBlocks call. The prompt-injection text comes
|
|
398
|
+
// from buildMemoryContext which excludes internal bookkeeping
|
|
399
|
+
// (cursor / health) and combines pinned + query-driven retrieval
|
|
400
|
+
// hits in one pass.
|
|
401
|
+
const [bp, ba, sp, ctx] = await Promise.allSettled([
|
|
376
402
|
memStore.listBlocks({ pinned: true, scope: 'both' }),
|
|
377
403
|
memStore.listBlocks({ scope: 'both' }),
|
|
378
404
|
memStore.search(args.userText, 8),
|
|
405
|
+
buildMemoryContext({ store: memStore, currentUserMessage: args.userText }),
|
|
379
406
|
]);
|
|
380
407
|
const pinnedBlocks = bp.status === 'fulfilled' ? bp.value : [];
|
|
381
408
|
const allBlocks = ba.status === 'fulfilled' ? ba.value : [];
|
|
382
409
|
const searchHits = sp.status === 'fulfilled' ? sp.value : [];
|
|
383
|
-
const firstErr = [bp, ba, sp].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
|
410
|
+
const firstErr = [bp, ba, sp, ctx].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
|
384
411
|
const memError = firstErr ? (firstErr.reason instanceof Error ? firstErr.reason.message : String(firstErr.reason)) : undefined;
|
|
385
412
|
|
|
386
|
-
memContext =
|
|
413
|
+
memContext = ctx.status === 'fulfilled' ? ctx.value.text : '';
|
|
387
414
|
|
|
388
415
|
cb({
|
|
389
416
|
type: 'memory_status',
|
|
@@ -470,7 +497,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
470
497
|
while (iter < MAX_ITERATIONS) {
|
|
471
498
|
iter += 1;
|
|
472
499
|
|
|
473
|
-
const history =
|
|
500
|
+
const history = trimOrphanToolResults(
|
|
501
|
+
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, HISTORY_TOKEN_BUDGET, estimateTokens),
|
|
502
|
+
);
|
|
474
503
|
|
|
475
504
|
assistantBlocksAccum = [];
|
|
476
505
|
let currentTextBuf = '';
|