@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.
@@ -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
- const isApi = cfg.type === 'api';
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
- // NOTE: do NOT write `cliType` from the entry here. cliType is the
1383
- // load-time discriminator between agents and CLI profiles (see the
1384
- // filter ~line 1349 `if (cfg.cliType || cfg.base) continue`).
1385
- // The Agents-add form seeds newAgent.cliType for cliDefaults lookup
1386
- // (taskFlags / resumeFlag presets), but persisting it would make
1387
- // the new agent show up as a profile on reload. `...existing`
1388
- // carries forward cliType for detected agents whose inline selector
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, // preserve profile-specific fields + any prior cliType
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 existing = settings.agents?.[newAgent.id] as any;
1452
- const kind = existing?.type === 'api' ? 'API profile' : existing?.cliType || existing?.base ? 'CLI profile' : 'agent';
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 => setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), cliType: e.target.value } } })}
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-...&#10;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)]">Shared across workspace and terminal override model, env vars, API endpoint</span>
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
- {/* All profiles (CLI + API). CLI profile marker is `cliType`
1749
- (new) or `base` (legacy); API profile is `type: 'api'`. */}
1750
- {Object.entries(settings.agents || {}).filter(([, cfg]: [string, any]) => cfg.cliType || cfg.base || cfg.type === 'api').map(([id, cfg]: [string, any]) => (
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.agents };
1791
+ const updated = { ...((settings as any).apiProfiles || {}) };
1755
1792
  delete updated[id];
1756
- setSettings({ ...settings, agents: updated });
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 — non-functional form captures a stale
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.agents?.[id]) {
1783
- const existing = prev.agents[id] as any;
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
- return { ...prev, agents: { ...(prev.agents || {}), [id]: cfg } };
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.agents || {})
1957
- .filter(([, a]: [string, any]) => a && a.type === 'api' && a.enabled !== false);
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 (type: API). LiteLLM proxies are supported via the Base URL field.
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
- return { cmd: resolved.cmd, args };
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
- return { cmd: config.path, args };
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) {
@@ -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
- // Custom agents + profiles from settings
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
- // API profile no CLI detection needed. Tagged with
82
- // backendType='api' so CLI-only dropdowns (Terminal launcher,
83
- // NewTaskModal etc.) can filter them out via
84
- // `agents.filter(a => a.backendType !== 'api')`. WorkspaceView's
85
- // API mode keeps them visible. Chat backend doesn't go through
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: 'api',
97
- provider: cfg.provider,
98
- model: cfg.model,
99
- apiKey: cfg.apiKey,
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
- // CLI profile (has base) — inherit from base agent
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 (not a profile) detect binary
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
+ }
@@ -16,16 +16,17 @@ import { loadSettings } from '../settings';
16
16
  import {
17
17
  appendMessage,
18
18
  getSession,
19
- listMessages,
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 agents = settings.agents || {};
154
+ const profiles = settings.apiProfiles || {};
132
155
 
133
- // Candidate API profiles: type=='api', enabled, with a usable apiKey (direct or via env).
134
- const candidates = Object.entries(agents).filter(([_, a]) => {
135
- if (!a || a.type !== 'api' || a.enabled === false) return false;
136
- const adapter = inferAdapter(a.provider);
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 agent profile with an API key. Add one under Settings → Agents (type: API) and pick it as the chat agent.' };
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 agent profile id), then settings.chatAgent, else first candidate.
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 = agents[preferredId] && agents[preferredId].type === 'api'
146
- ? agents[preferredId]
167
+ const profile = profiles[preferredId] && profiles[preferredId].enabled !== false
168
+ ? profiles[preferredId]
147
169
  : candidates[0]![1];
148
- const name = agents[preferredId]?.type === 'api' ? preferredId : candidates[0]![0];
170
+ const name = (profiles[preferredId] && profiles[preferredId].enabled !== false) ? preferredId : candidates[0]![0];
149
171
 
150
- const adapter = inferAdapter(profile.provider);
151
- const apiKey = pickApiKey(profile, adapter);
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: pickBaseUrl(profile, adapter),
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
- const [bp, ba, sp] = await Promise.allSettled([
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 = renderMemoryContext(allBlocks, searchHits);
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 = listMessages(args.sessionId);
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 = '';