agim-cli 1.3.5 → 1.3.7

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +20 -6
  2. package/dist/cli-ui/tui/app.js +1 -1
  3. package/dist/cli-ui/tui/app.js.map +1 -1
  4. package/dist/cli.js +25 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/core/a2a.d.ts.map +1 -1
  7. package/dist/core/a2a.js +6 -0
  8. package/dist/core/a2a.js.map +1 -1
  9. package/dist/core/access-token.d.ts.map +1 -1
  10. package/dist/core/access-token.js +16 -4
  11. package/dist/core/access-token.js.map +1 -1
  12. package/dist/core/approval-bus.d.ts +5 -0
  13. package/dist/core/approval-bus.d.ts.map +1 -1
  14. package/dist/core/approval-bus.js +32 -0
  15. package/dist/core/approval-bus.js.map +1 -1
  16. package/dist/core/commands/job.d.ts.map +1 -1
  17. package/dist/core/commands/job.js +10 -3
  18. package/dist/core/commands/job.js.map +1 -1
  19. package/dist/core/goal-rpc.d.ts +7 -0
  20. package/dist/core/goal-rpc.d.ts.map +1 -1
  21. package/dist/core/goal-rpc.js +47 -3
  22. package/dist/core/goal-rpc.js.map +1 -1
  23. package/dist/core/llm/exec-dispatcher.d.ts +2 -0
  24. package/dist/core/llm/exec-dispatcher.d.ts.map +1 -1
  25. package/dist/core/llm/exec-dispatcher.js +60 -6
  26. package/dist/core/llm/exec-dispatcher.js.map +1 -1
  27. package/dist/core/llm/fs-dispatcher.d.ts +3 -0
  28. package/dist/core/llm/fs-dispatcher.d.ts.map +1 -1
  29. package/dist/core/llm/fs-dispatcher.js +31 -12
  30. package/dist/core/llm/fs-dispatcher.js.map +1 -1
  31. package/dist/core/llm/imhub-dispatcher.d.ts.map +1 -1
  32. package/dist/core/llm/imhub-dispatcher.js +9 -5
  33. package/dist/core/llm/imhub-dispatcher.js.map +1 -1
  34. package/dist/core/llm/model-catalog.js +1 -1
  35. package/dist/core/llm/model-catalog.js.map +1 -1
  36. package/dist/core/llm/policy-approval-gate.d.ts.map +1 -1
  37. package/dist/core/llm/policy-approval-gate.js +17 -4
  38. package/dist/core/llm/policy-approval-gate.js.map +1 -1
  39. package/dist/core/llm/registry.d.ts +2 -2
  40. package/dist/core/llm/registry.js +1 -1
  41. package/dist/core/llm/web-dispatcher.d.ts +20 -0
  42. package/dist/core/llm/web-dispatcher.d.ts.map +1 -1
  43. package/dist/core/llm/web-dispatcher.js +70 -5
  44. package/dist/core/llm/web-dispatcher.js.map +1 -1
  45. package/dist/core/registry.d.ts.map +1 -1
  46. package/dist/core/registry.js +5 -19
  47. package/dist/core/registry.js.map +1 -1
  48. package/dist/core/router.js +1 -1
  49. package/dist/core/router.js.map +1 -1
  50. package/dist/core/schedule.d.ts.map +1 -1
  51. package/dist/core/schedule.js +6 -2
  52. package/dist/core/schedule.js.map +1 -1
  53. package/dist/core/skills/loader.js +3 -3
  54. package/dist/core/skills/loader.js.map +1 -1
  55. package/dist/core/types.d.ts +3 -0
  56. package/dist/core/types.d.ts.map +1 -1
  57. package/dist/plugins/agents/native/index.d.ts +0 -146
  58. package/dist/plugins/agents/native/index.d.ts.map +1 -1
  59. package/dist/plugins/agents/native/index.js +41 -1291
  60. package/dist/plugins/agents/native/index.js.map +1 -1
  61. package/dist/plugins/agents/pi-native/approval.d.ts.map +1 -1
  62. package/dist/plugins/agents/pi-native/approval.js +2 -3
  63. package/dist/plugins/agents/pi-native/approval.js.map +1 -1
  64. package/dist/plugins/agents/pi-native/factory.d.ts +3 -4
  65. package/dist/plugins/agents/pi-native/factory.d.ts.map +1 -1
  66. package/dist/plugins/agents/pi-native/factory.js +8 -14
  67. package/dist/plugins/agents/pi-native/factory.js.map +1 -1
  68. package/dist/plugins/agents/pi-native/index.d.ts +8 -8
  69. package/dist/plugins/agents/pi-native/index.d.ts.map +1 -1
  70. package/dist/plugins/agents/pi-native/index.js +78 -13
  71. package/dist/plugins/agents/pi-native/index.js.map +1 -1
  72. package/dist/plugins/agents/pi-native/provider-resolver.d.ts +4 -4
  73. package/dist/plugins/agents/pi-native/provider-resolver.d.ts.map +1 -1
  74. package/dist/plugins/agents/pi-native/provider-resolver.js +1 -1
  75. package/dist/plugins/agents/pi-native/provider-resolver.js.map +1 -1
  76. package/dist/plugins/agents/pi-native/tool-bridge.js +1 -1
  77. package/dist/plugins/agents/pi-native/tool-bridge.js.map +1 -1
  78. package/dist/plugins/agents/pi-native/tools.d.ts +3 -4
  79. package/dist/plugins/agents/pi-native/tools.d.ts.map +1 -1
  80. package/dist/plugins/agents/pi-native/tools.js +7 -9
  81. package/dist/plugins/agents/pi-native/tools.js.map +1 -1
  82. package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -1
  83. package/dist/plugins/messengers/discord/discord-adapter.js +14 -2
  84. package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -1
  85. package/package.json +1 -1
@@ -1,144 +1,36 @@
1
- // Native AgentAdapter agim's first in-process LLM agent (Stage 2 sub-PR #4).
1
+ // Shared runtime pieces for Agim Agent.
2
2
  //
3
- // The native adapter is the **opt-in backup** path: the CLI agents
4
- // (Claude Code / Codex / OpenCode) keep being the main route for IM
5
- // users, but operators can now ALSO register a native agent that runs
6
- // entirely inside agim no subprocess spawn, no claude.json, just
7
- // LLM API call + the agent loop from sub-PR #3.
8
- //
9
- // Concrete shape of one IM turn driven by this adapter:
10
- //
11
- // user message
12
- // ↓
13
- // AgentAdapter.sendPrompt(sessionId, prompt, history, opts)
14
- // ↓
15
- // 1. resolveProvider(role) ← pick the LLM (role-based)
16
- // 2. build LlmMessage[] from history+prompt
17
- // 3. assemble dispatcher
18
- // = combineDispatchers(builtin, mcp)
19
- // 4. assemble approval gate (from operator policy config)
20
- // 5. runAgentLoop(...) — multi-iter tool loop
21
- // 6. yield the final text as a SINGLE chunk (buffered) back to the
22
- // adapter's AsyncGenerator contract
23
- //
24
- // Stage 2 sub-PR #4 deliberately ships:
25
- // - one LLM call per IM turn (no streaming of incremental thinking
26
- // to IM; that's a separate feature)
27
- // - sequential tool execution within each iteration (matches
28
- // agent-loop's current contract)
29
- // - policy-based approval (mode=allow-list by default; operators
30
- // opt into looser modes via env)
31
- // - the existing tryIntrospect-style usage / cost audit accounting
32
- //
33
- // Out of scope here (covered later):
34
- // - IM-interactive approval cards (button taps / y-n IM reply); the
35
- // policy gate is sufficient for the initial release
36
- // - Streaming partial responses to IM (we already accumulate the
37
- // final text; partial streaming wants viewer-routing support too)
38
- // - Session persistence specific to native (we reuse the caller-
39
- // supplied history; agim's regular session manager handles
40
- // persistence across turns)
41
- import { logger as rootLogger } from '../../../core/logger.js';
42
- import { logInvocation } from '../../../core/audit-log.js';
43
- import { runAgentLoop, getAnyProvider, getProvider, getProviderByName, listProviders, } from '../../../core/llm/index.js';
44
- import { buildPolicyApprovalGate, describePolicy, } from '../../../core/llm/policy-approval-gate.js';
45
- // Tool assembly (defs + dispatch + per-call concurrency classifier) moved
46
- // into tool-registry.ts (#86); the per-dispatcher builder imports live there
47
- // now. MAX_INJECTED_SKILLS is still needed here for the system-prompt skills
48
- // cap (#85/T3).
49
- import { assembleNativeTools } from './tool-registry.js';
50
- import { listSkills, MAX_INJECTED_SKILLS } from '../../../core/skills/loader.js';
3
+ // The old in-process native AgentAdapter has been retired. Agim Agent is now
4
+ // implemented by the pi-agent-core backed adapter, registered
5
+ // under the public `native` / `agim` names. This module keeps only the shared
6
+ // prompt, policy, operator-role, and media helpers used by that single adapter.
7
+ import { randomUUID } from 'node:crypto';
8
+ import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, statSync as fsStatSync } from 'node:fs';
9
+ import { join as pathJoin, resolve as pathResolve, sep as pathSep } from 'node:path';
10
+ import { approvalBus } from '../../../core/approval-bus.js';
11
+ import { getAnyProvider, getProvider } from '../../../core/llm/index.js';
51
12
  import { describeRegistry as describeMcpRegistry } from '../../../core/llm/mcp-registry.js';
52
- import { resolveAgentCwd, defaultAgentCwd } from '../../../core/agent-cwd.js';
53
- import { handlePushOp } from '../../../core/push-rpc.js';
54
- import { approvalBus, threadKey as makeThreadKey } from '../../../core/approval-bus.js';
13
+ import { describePolicy, } from '../../../core/llm/policy-approval-gate.js';
55
14
  import { effectivePlanModeOn } from '../../../core/plan-mode-state.js';
56
- import { randomUUID as _nativeRandomUUID } from 'node:crypto';
57
- import { maybeCompactHistory } from '../../../core/llm/auto-compact.js';
58
- import { existsSync as fsExistsSync, statSync as fsStatSync, readFileSync as fsReadFileSync } from 'node:fs';
59
- import { resolve as pathResolve, sep as pathSep, join as pathJoin } from 'node:path';
60
15
  import { sanitizeForInjection, scanForInjectionAttempts } from '../../../core/prompt-injection-guard.js';
16
+ import { buildSkillsSummary } from '../../../core/skills/loader.js';
17
+ import { logger as rootLogger } from '../../../core/logger.js';
61
18
  const log = rootLogger.child({ component: 'native-agent' });
62
- /**
63
- * v1.2.147 — framework-level tool-call discipline injected into EVERY
64
- * native turn's system prompt. Sits at the top, before the operator
65
- * role definition, so it dominates any persona-level rules the user
66
- * authors. Pure prompt — paired with the runtime hallucination
67
- * detector (`detectHallucinatedToolCall`) that catches the failure
68
- * mode if the model ignores the rule.
69
- *
70
- * Why this is hard-coded, not optional:
71
- * - The failure (model narrates a tool call without emitting it) is
72
- * LLM-side and silent; we cannot fully prevent it. But almost every
73
- * instance we've seen would have been deterred by an explicit
74
- * "do not narrate, just emit" rule.
75
- * - Leaving this to operator AGENTS.md means every fresh install
76
- * reproduces the same harm before the operator notices.
77
- * - Escape hatch: IMHUB_NATIVE_TOOL_DISCIPLINE=off (or 0/false/no/
78
- * disable) lets advanced operators drop the block, e.g. when
79
- * measuring whether the prompt itself is hurting tool-call recall.
80
- */
81
19
  const TOOL_CALL_DISCIPLINE_PROMPT = [
82
20
  '## 工具调用纪律(agim 框架级硬约束)',
83
21
  '',
84
- '- 禁止用文本"描述/演练"工具调用。例如不可输出 `我现在调用 native_write_file: \\`\\`\\`python ...\\`\\`\\`` —— 想用工具就直接发 `toolCalls`,不要先用纯文本预告。',
85
- '- 工具调用必须真的发生:如果当轮没真正发出 `toolCalls`,就不要输出"已经写好 / 已经存好 / 已经调了 X"这类口径,更不要承诺"下一步直接写"。',
86
- '- 不确定是否该调用,用 `native_todo_write` 把意图留为 todo 让用户审,不要假装调用了又没调。',
87
- '- 用户追问"做了吗 / 写好了吗 / 又没消息了",先回顾本轮 `toolCalls` 历史;没有就直接坦白"没调成",禁止编造"这次直接写""不废话了"等空承诺——空承诺没有任何代价但会摧毁信任。',
22
+ '- 禁止用文本"描述/演练"工具调用;想用工具就直接发 toolCalls,不要先用纯文本预告。',
23
+ '- 工具调用必须真的发生:如果当轮没真正发出 toolCalls,就不要输出"已经写好 / 已经存好 / 已经调了 X"这类口径,不要假装调用了又没调。',
24
+ '- 用户追问"做了吗 / 写好了吗 / 又没消息了",先回顾本轮 toolCalls 历史;没有就直接坦白"没调成",禁止编造。',
88
25
  '- write / edit / exec 类副作用工具一次性写完整调用,不要分两步"先承诺再调用"。',
89
- '',
90
- '(运行时会有 hallucination 检测器在末轮抓"narrate without emit",触发会用复盘卡替换原回复。)',
91
26
  ].join('\n');
92
- /**
93
- * Read the IMHUB_NATIVE_TOOL_DISCIPLINE kill-switch. Default ON.
94
- * Recognized OFF values: 'off' / '0' / 'false' / 'no' / 'disable'.
95
- * Pairs with isHallucinationDetectorOn (hallucination-detector.ts): the
96
- * detector still fires when discipline is off, by design — discipline
97
- * removed is a prompting test, not a license to ship lies.
98
- */
99
27
  export function isToolDisciplineOn() {
100
28
  const raw = (process.env.IMHUB_NATIVE_TOOL_DISCIPLINE ?? '').toLowerCase().trim();
101
- if (raw === 'off' || raw === '0' || raw === 'false' || raw === 'no' || raw === 'disable') {
102
- return false;
103
- }
104
- return true;
29
+ return !(raw === 'off' || raw === '0' || raw === 'false' || raw === 'no' || raw === 'disable');
105
30
  }
106
- /**
107
- * v1.2.47 — system prompt is rebuilt per IM turn so the model sees:
108
- * - which LLM backend + role it's actually running on
109
- * - the agim process working directory (no per-thread cwd today)
110
- * - the live skill roster (names + one-line descriptions), with the
111
- * hint to call mcp__imhub__read_skill for full bodies
112
- * - external MCP servers currently connected
113
- *
114
- * Before v1.2.47 the prompt was a 4-line generic string; users
115
- * complained that asking "what model are you" / "what skills do you
116
- * have" got non-answers ("I don't know; ask the operator"). The
117
- * builder closes that information gap without leaking secrets — every
118
- * field surfaced here is operator-configured and non-sensitive.
119
- */
120
31
  export function buildSystemPrompt(provider, role, cwd, threadKey) {
121
- // T3 (context-as-budget): bound the tier-1 skills listing. listSkills()
122
- // already caps each description at MAX_DESCRIPTION_CHARS; here we also cap
123
- // the COUNT to MAX_INJECTED_SKILLS (the same ceiling buildSkillsSummary
124
- // uses) so a large catalog can't blow the per-turn system-prompt budget.
125
- // Sorted for stable ordering; overflow collapses into a "+N more" hint
126
- // that points the model at the on-demand read_skill / /skill list path.
127
- // (Previously this re-rolled an UNCAPPED `name: desc` line per skill.)
128
- const allSkills = listSkills().slice().sort((a, b) => a.name.localeCompare(b.name));
129
- const visibleSkills = allSkills.slice(0, MAX_INJECTED_SKILLS);
130
- const skillsBlock = visibleSkills.length
131
- ? visibleSkills
132
- .map((s) => {
133
- const desc = (s.description || '').trim() || '(no description; read body via mcp__imhub__read_skill)';
134
- const mark = s.available ? '' : ` (unavailable: ${s.unavailableReason ?? 'requires not met'})`;
135
- return ` - ${s.name}: ${desc}${mark}`;
136
- })
137
- .join('\n')
138
- + (allSkills.length > visibleSkills.length
139
- ? `\n … and ${allSkills.length - visibleSkills.length} more (use /skill list; read any with mcp__imhub__read_skill)`
140
- : '')
141
- : ' (no skill cards loaded; see docs/skills.md to add one)';
32
+ const skillsBlock = buildSkillsSummary().trim()
33
+ || '(no skill cards loaded; see docs/skills.md to add one)';
142
34
  const mcpReg = describeMcpRegistry();
143
35
  const externalMcp = mcpReg.servers.length
144
36
  ? mcpReg.servers
@@ -146,21 +38,12 @@ export function buildSystemPrompt(provider, role, cwd, threadKey) {
146
38
  .join('\n')
147
39
  : ' (none configured)';
148
40
  const lines = [];
149
- // v1.2.147 — framework-level tool-call discipline. Prepended BEFORE
150
- // the operator role so it dominates persona-level rules. Hard-coded
151
- // (not derived from a file) so every fresh agim install gets it, no
152
- // per-user setup. Pairs with the runtime hallucination detector.
153
41
  if (isToolDisciplineOn()) {
154
42
  lines.push('[agim framework rule — tool-call discipline]');
155
43
  lines.push(TOOL_CALL_DISCIPLINE_PROMPT);
156
44
  lines.push('[/agim framework rule]');
157
45
  lines.push('');
158
46
  }
159
- // Operator-supplied role definition. Reads <cwd>/AGENTS.md (seeded by
160
- // bootstrapAgentWorkspaces) and prepends it as a role block. Lets
161
- // operators customise the agent's identity, tone, and house rules
162
- // without touching code. Sanitised + scanned for prompt-injection
163
- // patterns (best-effort warn; never blocks turn).
164
47
  const opRole = readOperatorRole(cwd);
165
48
  if (opRole) {
166
49
  lines.push('[operator role definition]');
@@ -168,44 +51,37 @@ export function buildSystemPrompt(provider, role, cwd, threadKey) {
168
51
  lines.push('[/operator role definition]');
169
52
  lines.push('');
170
53
  }
171
- lines.push(`You are agim native — agim's in-process LLM agent talking to a user over an IM platform.`, ``, `Runtime:`, ` Backend: ${provider.providerType}:${provider.name}`, ` Role: ${role}`, ` Working directory: ${cwd}`, ``);
172
- // v1.2.119 — PlanMode banner. When the env knob is on, prepend a
173
- // high-priority instruction block so the model knows up-front it
174
- // can't write/exec. Without this banner, the deny verdict from
175
- // the policy gate gives a bare "tool call denied" message and the
176
- // model wastes iterations trying alternative write paths.
54
+ lines.push(`You are agim native — agim's in-process LLM agent talking to a user over an IM platform.`, '', 'Runtime:', ` Backend: ${provider.providerType}:${provider.name}`, ` Role: ${role}`, ` Working directory: ${cwd}`, '');
177
55
  if (isPlanModeOn(threadKey)) {
178
- lines.push(`⚠ Plan mode is ACTIVE`, ` - You MUST produce a read-only plan; native_write_file and native_exec are HARD-BLOCKED.`, ` - Use read tools freely: native_read_file / native_list_dir / native_glob / native_grep / native_web_fetch / native_web_search.`, ` - Exit handshake (v1.2.131): when the plan is ready, call`, ` native_exit_plan_mode({ plan: '<markdown of the steps>' })`, ` The user will see an Approve/Reject card. On approve you regain full write access and proceed immediately. On reject you stay in Plan Mode with the user's feedback in the tool result — revise and call again.`, ` - DO NOT just describe the plan in prose and stop — the user expects the exit handshake. Skip it only if the user explicitly asks for "no exit" / "just brainstorm".`, ``);
56
+ lines.push('⚠ Plan mode is ACTIVE', ' - You MUST produce a read-only plan; native_write_file and native_exec are HARD-BLOCKED.', ' - Use read tools freely: native_read_file / native_list_dir / native_glob / native_grep / native_web_fetch / native_web_search.', ' - When the plan is ready, call native_exit_plan_mode({ plan }) so the user can approve or reject it.', '');
179
57
  }
180
- lines.push(`Tools available beyond the four native built-ins (echo / now / sleep / random_uuid):`, ` - agim built-in MCP tools (mcp__imhub__*): read_skill, list_skills, save_memo, search_memos, update_memo, delete_memo, push_message, ask_user, call_agent, long_task, complete_goal`, ` - native filesystem tools: native_read_file, native_write_file, native_list_dir, native_glob, native_grep — constrained to your workspace cwd unless IMHUB_NATIVE_FS_RESTRICT=0`, ` - native web tools: native_web_fetch (r.jina.ai reader by default), native_web_search (duckduckgo → metaso fallback). Private IPs blocked.`, ` - native_exec(command, timeout_ms?, cwd?): run shell commands. Always approval-gated; bwrap sandbox when IMHUB_EXEC_SANDBOX=bwrap.`, ` - External MCP servers configured by the operator:`, externalMcp, ``, `Available skill cards (call mcp__imhub__read_skill('<name>') for the full body):`, skillsBlock, ``, `Guidance:`, ` - Be terse; avoid filler. Prefer tool use over guessing.`, ` - When uncertain, call mcp__imhub__ask_user(question, choices[]) instead of free-form back-and-forth.`, ` - When the user references something they told the bot before, search memos via mcp__imhub__search_memos.`, ``, `Tool selection priority (HARD RULE — v1.2.59):`, ` - For "read this file / list this dir / search this content / fetch this URL", you MUST FIRST try`, ` your own native tools: native_read_file, native_list_dir, native_glob, native_grep,`, ` native_web_fetch (if available). Do NOT delegate these to call_agent.`, ` - call_agent is reserved for tasks that genuinely need a CLI agent's specialised capabilities`, ` (writing/editing source code in a real repo → claude-code; long-running plans → codex; etc).`, ` - You have a per-turn call_agent cap (default 2). Burning it on file reads will leave you`, ` unable to delegate later when you actually need to.`, ``, `When to USE call_agent (v1.2.139 positive framing — pair with the HARD RULE above):`, ` - The task needs sustained source-code editing in a real repo → call_agent('claude-code', …)`, ` or call_agent('codex', …). Don't try to mimic them with native_write_file when the work is`, ` multi-file refactors or feature builds.`, ` - You need a second pair of eyes / cross-checking on your own conclusion → call_agent('codex',`, ` 'audit my findings: …'). Useful before committing or reporting.`, ` - The task is large enough that parallel research helps (e.g. survey 3 independent areas of`, ` a codebase) → fan-out via call_agent('native', …) so each sub-agent's context is fresh.`, ` - Tip: write the sub-agent prompt as a self-contained brief — they don't see this conversation.`, ``, `Verification subagent (T5 — harness pattern, the most reliably useful multi-agent move):`, ` - After you produce a SUBSTANTIVE result (a multi-step conclusion, a refactor, a data`, ` analysis, anything you're about to report or commit), spin up a FRESH subagent whose sole`, ` job is to verify it independently:`, ` call_agent('codex', 'Verify the result below against <source / acceptance criteria>.`, ` Report ONLY discrepancies or risks; if it checks out, say so. <paste result>')`, ` Use 'codex' for code/logic review, 'native' for a fresh-eyes fact/consistency check.`, ` - Give the verifier a SELF-CONTAINED brief: restate the claim AND how to check it, and paste`, ` the artifact. It does NOT see this conversation — "verify my findings" with nothing attached`, ` is useless.`, ` - Synthesize, don't delegate understanding: digest what you learned into a PRECISE spec before`, ` delegating implementation or verification. "Based on your findings, fix it" is an anti-pattern`, ` — you (the coordinator) must state exactly what to do, not hand off the thinking.`, ` - Don't over-verify: skip the verifier for trivial / low-stakes turns (it costs a call_agent hop).`, ``, `Web tool routing (HARD RULE — v1.2.64):`, ` - If the user provided a SPECIFIC URL (http://… or https://…) → native_web_fetch.`, ` - If the user wants to FIND / DISCOVER something by keywords ("查找最新 X" / "search for Y" /`, ` "find docs on Z" / "今天 / 最近的 W") → native_web_search FIRST. Don't guess a URL.`, ` - Common pattern: native_web_search(query) → pick a result → native_web_fetch(that.url).`, ` - NEVER call native_web_fetch with a URL you fabricated from the user's keywords.`, ``, `Short-input rule:`, ` - If the user's message is ONLY a slash command alias for an agent name (e.g. "/agim", "/native", "/llm",`, ` "/na", "/cc", "/oc") and you are already that agent, respond with ONE short line confirming`, ` your identity (e.g. "我是 native,正在听。"). Do NOT call any tool. The slash router handles`, ` actual agent switching; if it didn't switch, the user is already on this agent.`, ``, `Plan tracking (v1.2.124 — native_todo_write):`, ` - When the user gives you a task with ≥ 3 distinct steps, FIRST call native_todo_write({items}) to`, ` write out your plan, then update statuses as you complete each step.`, ` Status values: pending | in_progress | completed. Keep exactly one item in_progress at a time.`, ` - The tool result is a rendered markdown checklist; the user sees your progress.`, ` - Don't call native_todo_write for trivial one-step tasks — overhead.`, ` - Example sequence:`, ` 1) native_todo_write([{c:"Fetch market data",s:"in_progress"}, {c:"Analyse",s:"pending"}, {c:"Reply",s:"pending"}])`, ` 2) … do fetch via native_web_fetch …`, ` 3) native_todo_write([{c:"Fetch market data",s:"completed"}, {c:"Analyse",s:"in_progress"}, {c:"Reply",s:"pending"}])`, ` 4) … analysis …`, ` 5) write final answer to user`, ``, `Closure rule (v1.2.94 — HARD RULE):`, ` - When you finish a tool chain (read_file / web_fetch / native_exec / search_memos / etc.) you`, ` MUST write a short Chinese summary BEFORE stopping. The tool output by itself is not a`, ` user-facing answer — the user can't see the raw JSON / shell stdout. Always close with at`, ` least one sentence stating the finding / conclusion.`, ` - Do NOT end a turn with empty assistant text when you've just called tools. If you genuinely`, ` have nothing to add, say so explicitly ("已查完,未发现 X").`, ``, `Proactive memory rule (v1.2.96 — borrowed from Hermes Agent's "agent-curated memory"):`, ` - Before ending a substantive turn, scan the conversation for facts that should outlive the`, ` current chat. Persist them yourself via mcp__imhub__save_memo — don't wait for the user to`, ` ask you to remember.`, ` - Worth saving (call save_memo for each):`, ` · personal preferences ("我不喝咖啡" / "我用 vim"),`, ` · holdings or portfolio codes ("我持有 600519"),`, ` · recurring people / places ("我家在朝阳" / "爸爸生日 5月8日"),`, ` · stable identifiers (账号 / 邮箱 / API base / 配置路径),`, ` · explicit "记一下" / "remember this" instructions.`, ` - NOT worth saving: one-off questions, transient debugging context, tool outputs that are`, ` already cached elsewhere (memos point AT data, they're not a cache of data).`, ` - Each save_memo call is cheap. Two short memos beat one long one — small atomic facts`, ` search better. Add a 1-line user-facing acknowledgement so the user knows you remembered`, ` (e.g. "已记下 600519 是你的持仓").`, ``, `Long-task SOP (v1.2.93 — for any work you estimate will run > 10 minutes):`, ` - You CANNOT keep a long synchronous turn alive: the IM bridge times out around 30 min, and`, ` most useful work past the 10-min mark loses intermediate state if it crashes mid-flight.`, ` - Instead, use native_exec to invoke the agim bgjob wrapper, which spawns a detached worker`, ` that survives independent of this conversation:`, ` native_exec("/root/.claude/scripts/bgjob start <slug> -- /usr/bin/python3 /path/to/script.py [args]")`, ` Substitute python3 for the runtime you actually need. The wrapper returns a job_id; relay it`, ` to the user verbatim and tell them how to check back: \`bgjob status <id>\` / \`bgjob tail <id> -f\`.`, ` - When the user follows up asking about the job, native_exec calls like \`bgjob status <id>\` or`, ` \`bgjob tail <id> -n 100\` give you the current state + recent log lines.`, ` - The bwrap sandbox (when configured) is bypassed for this specific wrapper path so the`, ` setsid-detached worker actually survives. Any OTHER native_exec command remains sandboxed.`, ` - DO NOT use \`nohup ... &\` or backgrounded shell pipelines for long work — those die with the`, ` parent shell. bgjob is the only correct path on this platform.`, ``, `Python-RPC bridge (v1.2.97 — when a task means MANY similar tool calls):`, ` - When you would otherwise call mcp__imhub__* dozens of times in this chat turn (saving 30`, ` facts, fetching 50 stocks, scoring 100 candidates), DO NOT do it inline — that wastes the`, ` iteration budget and is likely to trip the stuck-loop detector. Write ONE Python script,`, ` run it in bgjob, and let it loop locally while calling back to agim's tool surface via the`, ` local RPC bridge agim sets up automatically for every native_exec child.`, ` - The Python sidecar lives at \`<npm install dir>/bin/agim_rpc.py\` (typically`, ` /usr/local/lib/node_modules/agim-cli/bin/agim_rpc.py — find it with`, ` \`node -e "console.log(require.resolve('agim-cli'))"\`). Import it and instantiate the client:`, ` from agim_rpc import client`, ` rpc = client() # reads env, validates token, no args needed`, ` memos = rpc.search_memos(query="茅台", k=10)`, ` for m in memos.get("rows", []):`, ` ...`, ` rpc.push_message(text="后台跑完了,结果是 X")`, ` - Available tools through the bridge (whitelist): search_memos, save_memo, read_skill,`, ` list_skills, push_message. Everything else (native_exec, fs writes, call_agent, long_task,`, ` ask_user) is NOT exposed — the worker already has a shell + filesystem.`, ` - The token is automatically injected via env (IMHUB_RPC_SOCKET + IMHUB_RPC_TOKEN), bound`, ` to THIS IM thread, valid for 24 h. The worker can only drive this thread; it cannot`, ` push_message into someone else's chat.`, ` - End the worker with rpc.push_message(text="…done…") so the user sees the result come back`, ` asynchronously. Don't expect the user to poll \`bgjob tail\` themselves.`);
58
+ lines.push('Tools available beyond the four native built-ins (echo / now / sleep / random_uuid):', ' - agim built-in MCP tools (mcp__imhub__*): read_skill, list_skills, save_memo, search_memos, update_memo, delete_memo, push_message, ask_user, call_agent, long_task, complete_goal', ' - native filesystem tools: native_read_file, native_write_file, native_list_dir, native_glob, native_grep — constrained to your workspace cwd unless IMHUB_NATIVE_FS_RESTRICT=0', ' - native web tools: native_web_fetch, native_web_search. Private IPs blocked by default.', ' - native_exec(command, timeout_ms?, cwd?): run shell commands. Always approval-gated.', ' - External MCP servers configured by the operator:', externalMcp, '', 'Agim Skills system-prompt injection:', skillsBlock, '', 'Guidance:', ' - Be terse; avoid filler. Prefer tool use over guessing.', ' - When uncertain, call mcp__imhub__ask_user(question, choices[]) instead of free-form back-and-forth.', ' - When the user references something they told the bot before, search memos via mcp__imhub__search_memos.', ' - For file/list/search/fetch requests, use native tools first; reserve call_agent for work that genuinely needs another CLI agent.', ' - Closure rule: after a tool chain, write a short Chinese summary before stopping.', '', 'Verification subagent:', ' - After a substantive result, use call_agent with a fresh verifier when a second pass is valuable.', " - Synthesize, don't delegate understanding: state the exact claim and how to verify it.", ' - Give the verifier a self-contained brief; it does not see this conversation.');
181
59
  return lines.join('\n');
182
60
  }
183
- const _operatorRoleCache = new Map();
61
+ const operatorRoleCache = new Map();
184
62
  function readRoleFile(path) {
185
63
  let st;
186
64
  try {
187
65
  st = fsStatSync(path);
188
66
  }
189
67
  catch {
190
- // Missing / unreadable → no role block; drop any stale cache entry.
191
- _operatorRoleCache.delete(path);
68
+ operatorRoleCache.delete(path);
192
69
  return '';
193
70
  }
194
- const cached = _operatorRoleCache.get(path);
195
- if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
71
+ const cached = operatorRoleCache.get(path);
72
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size)
196
73
  return cached.content;
197
- }
198
74
  let raw = '';
199
75
  try {
200
76
  raw = fsReadFileSync(path, 'utf-8');
201
77
  }
202
78
  catch {
203
- _operatorRoleCache.delete(path);
79
+ operatorRoleCache.delete(path);
204
80
  return '';
205
81
  }
206
82
  const trimmed = raw.trim();
207
83
  if (!trimmed) {
208
- _operatorRoleCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, content: '' });
84
+ operatorRoleCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, content: '' });
209
85
  return '';
210
86
  }
211
87
  try {
@@ -220,41 +96,15 @@ function readRoleFile(path) {
220
96
  }
221
97
  catch { /* scan is best-effort */ }
222
98
  const content = sanitizeForInjection(trimmed, 8000);
223
- _operatorRoleCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, content });
99
+ operatorRoleCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, content });
224
100
  return content;
225
101
  }
226
- /**
227
- * Resolve the operator role definition injected into native's system prompt.
228
- *
229
- * T4 (instruction hierarchy, distilled from the agentic-harness Memory
230
- * pattern's "local overrides win — always"). Instead of a single
231
- * `<cwd>/AGENTS.md`, discover a layered stack within the native workspace
232
- * and concatenate in ASCENDING priority so the most-local block appears last
233
- * and gets the most model attention. LOCAL WINS:
234
- *
235
- * 1. project — <cwd>/AGENTS.md (the shared native-workspace role)
236
- * 2. local — <cwd>/AGENTS.local.md (private override, not version-ctl'd)
237
- *
238
- * Both layers live under the native workspace cwd, so the stack is
239
- * self-contained (no dependency on a global ~/.agim file — native has a
240
- * single workspace, so "project" already IS the operator-global role; a
241
- * cross-workspace user layer can be added later if that changes).
242
- *
243
- * IMHUB_NATIVE_AGENT_ROLE_FILE still forces a single explicit file (operators
244
- * who pin one path bypass discovery entirely). Each layer is independently
245
- * memoized + injection-scanned + capped at 8000 chars by readRoleFile.
246
- */
247
102
  export function readOperatorRole(cwd) {
248
103
  const override = process.env.IMHUB_NATIVE_AGENT_ROLE_FILE;
249
- if (override && override.length > 0) {
250
- // An explicit pin is operator-chosen → trusted regardless of the gate.
104
+ if (override && override.length > 0)
251
105
  return readRoleFile(override);
252
- }
253
- // T6 — workspace trust gate. Discovered workspace files are skipped wholesale
254
- // when the workspace is marked untrusted (see isWorkspaceTrusted).
255
- if (!isWorkspaceTrusted()) {
106
+ if (!isWorkspaceTrusted())
256
107
  return '';
257
- }
258
108
  const parts = [];
259
109
  for (const p of [pathJoin(cwd, 'AGENTS.md'), pathJoin(cwd, 'AGENTS.local.md')]) {
260
110
  const c = readRoleFile(p);
@@ -263,30 +113,10 @@ export function readOperatorRole(cwd) {
263
113
  }
264
114
  return parts.join('\n\n');
265
115
  }
266
- /**
267
- * T6 — workspace trust gate (distilled from the agentic-harness Lifecycle
268
- * pattern's "trust is all-or-nothing; an untrusted workspace disables the
269
- * whole extension surface, not just suspicious parts").
270
- *
271
- * agim treats `<cwd>/AGENTS.md` + `AGENTS.local.md` as an operator-authored
272
- * role definition injected into the system prompt — a privileged surface. In
273
- * multi-tenant / A2A setups the native cwd can point at a directory whose
274
- * contents are NOT fully operator-controlled, where an attacker-planted
275
- * AGENTS.md is a prompt-injection vector. Setting
276
- * `IMHUB_NATIVE_TRUST_WORKSPACE=off` (or 0/false/no) makes readOperatorRole
277
- * skip ALL workspace-discovered role files at once. Default is trusted
278
- * (on) for backward compatibility — operators opt into the stricter posture.
279
- *
280
- * An explicit `IMHUB_NATIVE_AGENT_ROLE_FILE` bypasses the gate: the operator
281
- * pinned that exact file deliberately, so it stays trusted.
282
- */
283
116
  export function isWorkspaceTrusted() {
284
117
  const raw = (process.env.IMHUB_NATIVE_TRUST_WORKSPACE ?? '').toLowerCase().trim();
285
118
  return !(raw === 'off' || raw === '0' || raw === 'false' || raw === 'no');
286
119
  }
287
- /** Role priority for picking which LLM backend powers the native chat
288
- * turn. First found wins. Operators can override the role via
289
- * `IMHUB_NATIVE_AGENT_ROLE`. */
290
120
  const DEFAULT_ROLE_FALLBACK = ['native-chat', 'cheap'];
291
121
  function resolveRole() {
292
122
  const raw = process.env.IMHUB_NATIVE_AGENT_ROLE;
@@ -294,371 +124,6 @@ function resolveRole() {
294
124
  return [raw.trim(), ...DEFAULT_ROLE_FALLBACK];
295
125
  return DEFAULT_ROLE_FALLBACK.slice();
296
126
  }
297
- /**
298
- * v1.2.91 / v1.2.92 — render a structured recap when a native turn
299
- * ends without a normal "stop + text" completion. Two failure modes
300
- * share the same skeleton:
301
- *
302
- * - `empty` : finishReason='stop' but text=''. Common cause:
303
- * model finished a tool chain (search / fetch /
304
- * read / ask_user) and skipped the closing summary.
305
- * - `max_iter` : the loop hit IMHUB_NATIVE_AGENT_MAX_ITER without
306
- * the model deciding to stop. The model wanted to
307
- * keep going. We were the ones who pulled the plug.
308
- *
309
- * Both surface:
310
- * 1. what tool calls actually ran (✓ / ✗ / ⚠️, deduped by name)
311
- * 2. a 160-char preview of the last tool's output
312
- * 3. a plain-language guess at why we're here
313
- * 4. concrete continuation options
314
- *
315
- * Pure formatting of AgentLoopResult fields; no second LLM call.
316
- */
317
- function composeUnfinishedTurnRecap(result, kind, maxIter) {
318
- const tools = result.toolCalls;
319
- if (tools.length === 0) {
320
- // No tools called AND no text emitted — the model literally said
321
- // nothing. Usually a misjudged "nothing to do" or a provider
322
- // quirk on a single short prompt. (max_iter with 0 tools is
323
- // unusual but possible if the model returned empty assistant
324
- // text every iteration; treat the same way.)
325
- return [
326
- '🧐 这一轮没说话也没动工具。',
327
- '可能是模型把请求误判成了"无事可做",或者提供方返回了空响应。',
328
- '',
329
- '怎么继续:',
330
- ' · 直接告诉我具体要做什么(多给点上下文)',
331
- ' · 或把上一条请求换种说法再发',
332
- ' · 或 /cc / /oc / /cs 切到别的智能体接手',
333
- ].join('\n');
334
- }
335
- // Group identical tool names so a chain like 6× read_file collapses
336
- // into "read_file ×6" rather than 6 list items.
337
- const counts = new Map();
338
- for (const t of tools) {
339
- const e = counts.get(t.name);
340
- if (e) {
341
- e.count += 1;
342
- if (t.isError)
343
- e.errors += 1;
344
- }
345
- else {
346
- counts.set(t.name, { count: 1, errors: t.isError ? 1 : 0 });
347
- }
348
- }
349
- const intro = kind === 'stuck_loop'
350
- ? `🛑 检测到死循环:模型在第 ${result.iterations} 步连续 3 次调用同一个工具拿到完全一样的结果,已提前停下。已执行的工具调用:`
351
- : kind === 'max_iter'
352
- ? `⚠️ 这一轮干到第 ${result.iterations} 步还没收尾,被安全上限切掉了。已执行的工具调用:`
353
- : '🧐 这一轮没写出收尾文字就结束了,但中间有做事。已执行的工具调用:';
354
- const lines = [intro];
355
- for (const [name, { count, errors }] of counts) {
356
- const mark = errors === 0 ? '✓' : errors === count ? '✗' : '⚠️';
357
- const tail = count > 1 ? ` ×${count}` : '';
358
- const errTail = errors > 0 && errors < count ? `(其中 ${errors} 次失败)` : '';
359
- lines.push(` ${mark} ${name}${tail}${errTail}`);
360
- }
361
- const last = tools[tools.length - 1];
362
- if (last) {
363
- const preview = (last.preview ?? '').trim().slice(0, 160).replace(/\s+/g, ' ');
364
- lines.push('');
365
- lines.push(`最后一步:${last.name}${last.isError ? '(失败)' : ''}`);
366
- if (preview)
367
- lines.push(` └ 结果摘要:${preview}${(last.preview?.length ?? 0) > 160 ? '…' : ''}`);
368
- }
369
- // Quick why-did-it-stop hint based on what we have.
370
- lines.push('');
371
- if (kind === 'stuck_loop' && last) {
372
- lines.push(`判定:${last.name} 工具连续 3 次返回了完全一样的内容(${last.isError ? '同一个错误' : '同一份输出'}),` +
373
- `继续重试不会带来新结果。请换个写法 / 换个参数 / 换个工具,或者把任务拆小。`);
374
- }
375
- else if (kind === 'max_iter') {
376
- lines.push(`猜测:模型还想继续,但跑到第 ${result.iterations} 步触发了安全上限(IMHUB_NATIVE_AGENT_MAX_ITER=${maxIter})。任务规模偏大或卡在某一步反复重试。`);
377
- }
378
- else if (last && !last.isError) {
379
- lines.push('猜测:模型在工具结果上停手了,没写最终结论。常见原因是它把工具的返回当成了"完整答案"。');
380
- }
381
- else if (last && last.isError) {
382
- lines.push('猜测:最后一步工具失败了,模型没生成补救/解释文字就停了。');
383
- }
384
- else {
385
- lines.push(`猜测:模型在第 ${result.iterations} 轮结束时主动停止(finishReason=${result.finishReason})。`);
386
- }
387
- lines.push('');
388
- if (kind === 'stuck_loop') {
389
- lines.push('怎么继续:');
390
- lines.push(' · 告诉我换什么写法 / 换什么参数(最有用)');
391
- lines.push(' · 或把任务拆小一些(先解决卡住的那一步)');
392
- lines.push(' · 或 /cc / /oc / /cs 切到别的智能体接手');
393
- }
394
- else if (kind === 'max_iter') {
395
- lines.push('怎么继续:');
396
- lines.push(' · 回「继续」让我接着把剩下的事做完');
397
- lines.push(' · 或把任务拆小一些(先做 A,再做 B)');
398
- lines.push(` · 或调高上限:在 .agim/env 加一行 IMHUB_NATIVE_AGENT_MAX_ITER=${Math.min(maxIter * 2, 100)} 后重启服务`);
399
- }
400
- else {
401
- lines.push('要继续吗?');
402
- lines.push(' · 回「继续」让我接着推进');
403
- lines.push(' · 或直接告诉我下一步具体做什么');
404
- lines.push(' · 或 /cc / /oc / /cs 切到别的智能体接手');
405
- }
406
- return lines.join('\n');
407
- }
408
- /**
409
- * v1.2.98 — render the off-track recap from the goal-critic's verdict.
410
- * Different shape from `composeUnfinishedTurnRecap` because the critic
411
- * gave us a verbatim Chinese reason + optional redirect; we don't have
412
- * to invent fail-mode hints. Layout:
413
- *
414
- * 🧭 目标偏离检测:模型偏离了原目标。
415
- * 原因:<critic.reason>
416
- * 建议方向:<critic.redirect> (only if non-empty)
417
- *
418
- * 已执行的工具调用:
419
- * ✓ tool_a ×2
420
- * ✗ tool_b ×3 (失败)
421
- *
422
- * 要继续吗?
423
- * · 回「继续」按原方向接着推
424
- * · 回「换」按上面的建议方向走
425
- * · 或自己说一个新方向 / 切到别的 agent
426
- */
427
- function composeOffTrackRecap(result, reason, redirect) {
428
- const lines = [];
429
- lines.push('🧭 目标偏离检测:模型在原目标上不再有进展。');
430
- if (reason)
431
- lines.push(`原因:${reason}`);
432
- if (redirect)
433
- lines.push(`建议方向:${redirect}`);
434
- lines.push('');
435
- if (result.toolCalls.length > 0) {
436
- lines.push(`已执行的工具调用(共 ${result.toolCalls.length} 次):`);
437
- const counts = new Map();
438
- for (const t of result.toolCalls) {
439
- const e = counts.get(t.name);
440
- if (e) {
441
- e.count += 1;
442
- if (t.isError)
443
- e.errors += 1;
444
- }
445
- else
446
- counts.set(t.name, { count: 1, errors: t.isError ? 1 : 0 });
447
- }
448
- for (const [name, { count, errors }] of counts) {
449
- const mark = errors === 0 ? '✓' : errors === count ? '✗' : '⚠️';
450
- const tail = count > 1 ? ` ×${count}` : '';
451
- const errTail = errors > 0 && errors < count ? `(其中 ${errors} 次失败)` : '';
452
- lines.push(` ${mark} ${name}${tail}${errTail}`);
453
- }
454
- lines.push('');
455
- }
456
- lines.push('要继续吗?');
457
- if (redirect) {
458
- lines.push(' · 回「继续」按原方向接着推(已经被判定走不通)');
459
- lines.push(' · 回「换」按上面的建议方向走(推荐)');
460
- }
461
- else {
462
- lines.push(' · 回「继续」按原方向接着推');
463
- }
464
- lines.push(' · 或自己告诉我一个新方向');
465
- lines.push(' · 或 /cc / /oc / /cs 切到别的智能体接手');
466
- return lines.join('\n');
467
- }
468
- /**
469
- * v1.2.147 — recap for the "hallucinated tool-call" failure mode.
470
- *
471
- * Trigger: agent-loop detected the model's final text narrates a
472
- * native_* tool invocation (e.g. "我现在调用 native_write_file:
473
- * ```python ...```" or "let me invoke native_exec") but the response
474
- * carried zero real toolCalls. The model promised an action it did not
475
- * take. Without this branch the lie would get shipped to the user as a
476
- * normal reply.
477
- *
478
- * Distinct from the empty / max-iter / stuck-loop recaps because the
479
- * fail-mode is model-side rather than budget-side: the model is fine,
480
- * just not in a tool-calling mood. The recap points the user at a
481
- * backend swap as the most reliable next step, since prompt tweaking
482
- * has limited leverage when the model has already drifted out of the
483
- * function-calling schema. Pure formatting; no LLM call.
484
- */
485
- function composeHallucinatedToolRecap(result, backend) {
486
- const lines = [];
487
- lines.push('🧐 模型说要调用工具,但实际上没真的发起调用。');
488
- lines.push('为了不让你看到空承诺,已经把这次回复挡下。');
489
- lines.push('');
490
- lines.push(`当前 native-chat 后端:${backend}`);
491
- if (result.toolCalls.length > 0) {
492
- lines.push(`本轮在此之前已真正完成了 ${result.toolCalls.length} 次工具调用,那部分有效。`);
493
- }
494
- else {
495
- lines.push('本轮没有任何真实工具调用产出。');
496
- }
497
- lines.push('');
498
- lines.push('怎么继续:');
499
- lines.push(' · 直接回「重试」让我重新跑一遍这一步');
500
- lines.push(' · 或在 /settings/llm 切换到工具调用更稳定的后端(可选再把 native-chat 角色绑定到它)');
501
- lines.push(' · 或 /cc / /oc / /cs 切到别的智能体接手这一步');
502
- lines.push('');
503
- lines.push('(检测器可用 IMHUB_NATIVE_HALLUCINATION_DETECT=off 关掉)');
504
- return lines.join('\n');
505
- }
506
- /**
507
- * v1.2.142 — Stage-report retry. Replaces v1.2.94's empty-only auto-
508
- * summary with a single helper used by all four "unhappy ending"
509
- * branches (empty / max_iter / stuck_loop / off_track).
510
- *
511
- * The retry asks the same provider, with no tools, for a *user-facing
512
- * stage report* given the work already done — not a "final answer". The
513
- * prompt explicitly tolerates partial / failed work; failed tool
514
- * results get described in plain language ("couldn't fetch the news
515
- * page — anti-bot"), never as `tool_name ×N`.
516
- *
517
- * Returns the produced text + cost delta on success; null when the
518
- * provider returned nothing or threw. Caller falls back to the
519
- * technical `composeUnfinishedTurnRecap` recap on null.
520
- *
521
- * One LLM call, 60s deadline, tools=[] so it can't keep chaining.
522
- * Env IMHUB_NATIVE_STAGE_REPORT=off disables the retry entirely
523
- * (caller goes straight to the technical recap) — useful for debug.
524
- */
525
- export async function tryStageReport(opts) {
526
- if (isStageReportDisabled())
527
- return null;
528
- const recent = opts.result.toolCalls.slice(-20);
529
- const success = recent.filter((t) => !t.isError);
530
- const failed = recent.filter((t) => t.isError);
531
- const fmtPreview = (p, cap) => (p ?? '').trim().slice(0, cap).replace(/\s+/g, ' ');
532
- const successBlock = success.length === 0
533
- ? '(无成功的中间结果)'
534
- : success
535
- .map((t, i) => `${i + 1}. ${fmtPreview(t.preview, 300)}${(t.preview?.length ?? 0) > 300 ? '…' : ''}`)
536
- .join('\n');
537
- const failedBlock = failed.length === 0
538
- ? '(无失败)'
539
- : failed
540
- .map((t, i) => `${i + 1}. ${fmtPreview(t.preview, 200)}${(t.preview?.length ?? 0) > 200 ? '…' : ''}`)
541
- .join('\n');
542
- const kindHint = {
543
- empty: '工具调用做完了但没写收尾文字。',
544
- max_iter: `执行步数达到上限(${opts.result.iterations} 步)被切断。`,
545
- stuck_loop: '检测到同一工具连续重复执行(卡循环),提前停止。',
546
- off_track: '目标偏离检测:当前推进方向被判定为离原任务太远。',
547
- }[opts.kind];
548
- const reasonHint = opts.kind === 'off_track' && opts.offTrackReason
549
- ? `\n[偏离原因] ${opts.offTrackReason}`
550
- : '';
551
- const messages = [
552
- {
553
- role: 'system',
554
- content: '你是 agim native 智能体。用户委托你做一项任务,你做了一些工作,但当前轮次未能正常收尾。\n' +
555
- '\n' +
556
- '请用中文给用户一份**阶段性报告**,目的是让用户拿到尽可能多的可用结果 + 知道接下来怎么做:\n' +
557
- '\n' +
558
- '【已经拿到 / 做到】基于成功的中间结果,给出具体数据、事实或结论。直接写内容,不要写"我调用了 X 工具"。如果没有任何可用结果,写"暂无"。\n' +
559
- '【没拿到 / 卡在哪】基于失败的中间结果,用普通中文描述卡点:例如"新浪财经页面抓不到(疑似反爬)"、"某 shell 命令未找到"、"目标 URL 超时"。**不要出现工具名(native_xxx / mcp__imhub__xxx)**。没有失败就跳过本节。\n' +
560
- '【下一步建议】给用户 1-3 个具体可执行的下一步,可包括"继续"、"换数据源"、"缩小范围"、"终止"等。\n' +
561
- '\n' +
562
- '硬约束:\n' +
563
- '- 不要再调任何工具。\n' +
564
- '- 不要列工具调用清单 / 工具计数。\n' +
565
- '- 不要把失败粉饰成完成。\n' +
566
- '- 失败的内容里不要让用户做诊断(除非真的需要他抉择)。\n',
567
- },
568
- { role: 'user', content: opts.prompt },
569
- {
570
- role: 'user',
571
- content: `[本轮中止原因] ${kindHint}${reasonHint}\n\n` +
572
- `[成功的中间结果(最近 ${success.length}/${opts.result.toolCalls.length} 次,截 300 字预览)]\n${successBlock}\n\n` +
573
- `[失败的中间结果(最近 ${failed.length} 次,截 200 字错误预览)]\n${failedBlock}\n\n` +
574
- `[请基于上面的真实中间结果,按系统消息里的三节结构给阶段性报告。]`,
575
- },
576
- ];
577
- try {
578
- log.info({
579
- event: 'native.turn.stage_report.start',
580
- sessionId: opts.sessionId,
581
- backend: opts.provider.name,
582
- kind: opts.kind,
583
- total: opts.result.toolCalls.length,
584
- successCount: success.length,
585
- failedCount: failed.length,
586
- }, `stage report → ${opts.kind} (succ=${success.length} fail=${failed.length} total=${opts.result.toolCalls.length})`);
587
- const retry = await opts.provider.chat(messages, {
588
- model: opts.model,
589
- timeoutMs: 60_000,
590
- signal: opts.signal,
591
- });
592
- if (!retry.text || !retry.text.trim()) {
593
- log.warn({
594
- event: 'native.turn.stage_report.empty',
595
- sessionId: opts.sessionId,
596
- backend: opts.provider.name,
597
- kind: opts.kind,
598
- }, 'stage report returned empty text — falling through to recap');
599
- return null;
600
- }
601
- log.info({
602
- event: 'native.turn.stage_report.ok',
603
- sessionId: opts.sessionId,
604
- backend: opts.provider.name,
605
- kind: opts.kind,
606
- textLen: retry.text.length,
607
- costUsd: retry.usage?.costUsd ?? null,
608
- }, `stage report produced ${retry.text.length} chars`);
609
- return {
610
- text: retry.text,
611
- costUsd: typeof retry.usage?.costUsd === 'number' ? retry.usage.costUsd : null,
612
- };
613
- }
614
- catch (err) {
615
- log.warn({
616
- event: 'native.turn.stage_report.failed',
617
- sessionId: opts.sessionId,
618
- backend: opts.provider.name,
619
- kind: opts.kind,
620
- err: err instanceof Error ? err.message : String(err),
621
- }, 'stage report threw — falling through to recap');
622
- return null;
623
- }
624
- }
625
- /** v1.2.142 — Operator kill switch for the stage-report retry. Defaults
626
- * ON. Set `IMHUB_NATIVE_STAGE_REPORT=off` (or `0` / `no` / `false`) to
627
- * skip the retry and go straight to the technical recap — debug only. */
628
- function isStageReportDisabled() {
629
- const raw = (process.env.IMHUB_NATIVE_STAGE_REPORT ?? '').toLowerCase().trim();
630
- return raw === 'off' || raw === '0' || raw === 'no' || raw === 'false';
631
- }
632
- /** Read IMHUB_NATIVE_AGENT_MAX_ITER, clamp to [1, 100], default 50.
633
- * v1.2.136: bumped from 20 → 50 after observing real-world CR / multi-step
634
- * refactor tasks routinely needed 28-35 iters and 20 was cutting them off.
635
- * 50 covers most cases with margin; the 30-min IM hard timeout is the
636
- * earlier ceiling for genuinely long runs, and v1.2.122 semantic stuck-
637
- * loop detection catches runaway model behaviour well before 50.
638
- * v1.2.92 — previously this env var was named in the user-facing
639
- * banner ("raise IMHUB_NATIVE_AGENT_MAX_ITER") but NOTHING in code
640
- * read it. Now actually wired into runAgentLoop. */
641
- function resolveMaxIterations() {
642
- const raw = process.env.IMHUB_NATIVE_AGENT_MAX_ITER;
643
- if (!raw?.trim())
644
- return 50;
645
- const n = parseInt(raw, 10);
646
- if (!Number.isFinite(n) || n <= 0)
647
- return 50;
648
- return Math.min(Math.max(n, 1), 100);
649
- }
650
- /** v1.2.112 — opt the native agent into the agent-loop's streaming
651
- * code path. Default ON: the headline benefit is preserving partial
652
- * assistant text when IM's 30-min hard timeout fires mid-response.
653
- * Ops can flip via `IMHUB_NATIVE_STREAM_PARTIAL=off` if a vendor's
654
- * streaming endpoint misbehaves. Note the agent-loop also honours
655
- * process-wide `IMHUB_AGENT_LOOP_STREAM=off` as a global kill switch. */
656
- function resolveNativeStreamPartial() {
657
- const raw = (process.env.IMHUB_NATIVE_STREAM_PARTIAL ?? '').toLowerCase().trim();
658
- if (raw === 'off' || raw === 'false' || raw === '0' || raw === 'no')
659
- return false;
660
- return true;
661
- }
662
127
  function pickProvider() {
663
128
  for (const role of resolveRole()) {
664
129
  const p = getProvider(role);
@@ -670,148 +135,38 @@ function pickProvider() {
670
135
  return { provider: fallback, role: 'auto' };
671
136
  return null;
672
137
  }
673
- /**
674
- * Build the ordered fallback chain of (provider, role) pairs the agent
675
- * loop will retry against. The chain begins with whatever pickProvider
676
- * picks (operator's primary), then appends each remaining role that has
677
- * a registered provider — de-duplicated by provider name so we never
678
- * retry the exact same backend.
679
- *
680
- * The loop driver in sendPrompt walks this chain when an iteration ends
681
- * with a TRANSIENT provider error (HTTP 5xx / network reset / 408).
682
- * Non-transient errors (4xx misconfig, schema validation) end the turn
683
- * immediately — retrying won't help and just multiplies cost.
684
- */
685
- function pickProviderChain() {
686
- const chain = [];
687
- const seen = new Set();
688
- for (const role of resolveRole()) {
689
- const p = getProvider(role);
690
- if (!p)
691
- continue;
692
- if (seen.has(p.name))
693
- continue;
694
- seen.add(p.name);
695
- chain.push({ provider: p, role });
696
- }
697
- // Roles are optional now. If no role resolves (or role-resolved set is
698
- // incomplete), append any remaining configured backends so native can run
699
- // as soon as one backend is configured.
700
- for (const meta of listProviders()) {
701
- const p = getProviderByName(meta.name);
702
- if (!p || seen.has(p.name))
703
- continue;
704
- seen.add(p.name);
705
- chain.push({ provider: p, role: 'auto' });
706
- }
707
- return chain;
138
+ function splitEnvList(raw) {
139
+ if (!raw)
140
+ return [];
141
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
708
142
  }
709
- /** Resolve the operator's approval policy from env. Defaults to
710
- * `allow-list` mode with NO allow entries — equivalent to "no tool
711
- * calls". Operators that want native to actually call tools must
712
- * configure at least one of these env vars:
713
- *
714
- * IMHUB_NATIVE_AGENT_MODE=allow-all|read-only|allow-list|deny-all
715
- * IMHUB_NATIVE_AGENT_AUTOALLOW=tool1,tool2,...
716
- * IMHUB_NATIVE_AGENT_DENYLIST=tool1,...
717
- *
718
- * The `read-only` mode is the recommended starter — it accepts every
719
- * tool whose name starts/ends with a read-only verb (`read_*` /
720
- * `list_*` / `get_*` / etc.) and the four built-ins
721
- * (`native_echo`/`now`/`random_uuid` — `sleep_ms` excluded).
722
- */
143
+ const PLAN_MODE_DENY_TOOLS = ['native_write_file', 'native_exec'];
723
144
  export function resolvePolicy(threadKey) {
724
145
  const mode = (process.env.IMHUB_NATIVE_AGENT_MODE || 'allow-list').toLowerCase();
725
146
  const autoAllow = splitEnvList(process.env.IMHUB_NATIVE_AGENT_AUTOALLOW);
726
147
  const denyList = splitEnvList(process.env.IMHUB_NATIVE_AGENT_DENYLIST);
727
- // Allow the 3 safe native builtins + all first-party imhub MCP tools by
728
- // default. The imhub tools are in-process (no external API cost, no
729
- // network side-effects beyond what the IM bridge would do anyway) and
730
- // are part of agim's own surface — denying them by default is the same
731
- // class of misconfiguration as denying agim itself. Operators can
732
- // remove individual entries via IMHUB_NATIVE_AGENT_DENYLIST.
733
148
  const defaultBuiltins = [
734
149
  'native_echo', 'native_now', 'native_random_uuid',
735
- // fs read-only tools (v1.2.58). write_file deliberately omitted —
736
- // it mutates the workspace, even though sensitive-paths + workspace
737
- // restriction prevent escape; an IM card per write keeps operator
738
- // awareness intact. Operators who trust native can add it via
739
- // IMHUB_NATIVE_AGENT_AUTOALLOW=native_write_file.
740
150
  'native_read_file', 'native_list_dir', 'native_glob', 'native_grep',
741
- // web tools (v1.2.61) — read-only, safe-by-default. SSRF rules
742
- // still apply to private IPs unless IMHUB_NATIVE_WEB_ALLOW_PRIVATE=1.
743
151
  'native_web_fetch', 'native_web_search',
744
152
  'mcp__imhub__read_skill', 'mcp__imhub__list_skills',
745
153
  'mcp__imhub__save_memo', 'mcp__imhub__search_memos',
746
154
  'mcp__imhub__update_memo', 'mcp__imhub__delete_memo',
747
155
  'mcp__imhub__push_message', 'mcp__imhub__ask_user',
748
- 'mcp__imhub__call_agent',
749
- // v1.2.63 — long-goal management tools. State changes are scoped
750
- // to the per-thread goal row, which is also the only thing the
751
- // /goal slash command lets the user mutate directly. No external
752
- // side effects, so safe-by-default.
753
- 'mcp__imhub__long_task', 'mcp__imhub__complete_goal',
754
- // v1.2.124 — TodoWrite-style plan tracker. In-process, in-memory,
755
- // per-thread; zero side effects beyond the model's own state. Safe
756
- // to default-allow; never asks the user.
757
- 'native_todo_write',
758
- // v1.2.131 — Plan-mode exit handshake. The tool's OWN dispatcher
759
- // raises the user-facing approval card (with the plan markdown
760
- // payload); auto-allowing here just keeps the policy gate from
761
- // double-prompting. Without this entry an operator using allow-list
762
- // mode would see two cards back-to-back ("native_exit_plan_mode
763
- // ok?" then "approve plan?").
764
- 'native_exit_plan_mode',
156
+ 'mcp__imhub__call_agent', 'mcp__imhub__long_task', 'mcp__imhub__complete_goal',
157
+ 'native_todo_write', 'native_exit_plan_mode',
765
158
  ];
766
159
  const effectiveAllow = mode === 'allow-list'
767
160
  ? Array.from(new Set([...autoAllow, ...defaultBuiltins]))
768
161
  : autoAllow;
769
- // v1.2.119 — PlanMode hard-denies write + exec tools.
770
- // v1.2.120 — PlanMode now ALSO honours per-thread overrides written
771
- // by the /plan slash command. Pass `threadKey` to apply
772
- // the override; omit it (CLI boot context, tests) for
773
- // the env-only path.
774
- //
775
- // Mirrors opencode's plan-mode behaviour: agent must produce a
776
- // read-only plan; trying to call native_write_file / native_exec is
777
- // denied with a clear structured message instructing the model to
778
- // summarise the plan instead.
779
- //
780
- // We extend the user-supplied denyList rather than replacing the
781
- // policy mode — read-only tools (Read/Grep/Glob/web_fetch/web_search/
782
- // MCP read tools) continue to pass through autoAllow as before.
783
162
  const effectiveDeny = isPlanModeOn(threadKey)
784
163
  ? Array.from(new Set([...denyList, ...PLAN_MODE_DENY_TOOLS]))
785
164
  : denyList;
786
165
  return { mode, autoAllow: effectiveAllow, denyList: effectiveDeny };
787
166
  }
788
- /** v1.2.119 — tools blocked when PlanMode is on. The list is intentionally
789
- * narrow: writes to the workspace + arbitrary shell exec. Read-only
790
- * fs/grep/glob/web/MCP tools stay enabled so the agent can still
791
- * research and draft a plan. */
792
- const PLAN_MODE_DENY_TOOLS = [
793
- 'native_write_file',
794
- 'native_exec',
795
- ];
796
- /** Plan mode resolution (v1.2.120):
797
- * - `threadKey` given (and matches a /plan-toggled row) → use that row
798
- * - `threadKey` given but no row → fall through to env
799
- * - `threadKey` omitted → env-only (back-compat for CLI boot, tests)
800
- */
801
167
  export function isPlanModeOn(threadKey) {
802
168
  return effectivePlanModeOn(threadKey);
803
169
  }
804
- /**
805
- * v1.2.60 — bridge native's policy gate to the approval-bus so tools
806
- * that the gate would otherwise silently deny instead surface an IM
807
- * approval card. Mirrors how claude-code's MCP sidecar uses
808
- * registerSyntheticPending. Resolves to 'allow' / 'deny' from the
809
- * user's button tap / text reply / auto-allow rule. Throws (caught by
810
- * the gate) when the bus can't reach the user — caller falls back to
811
- * silent-deny in that case.
812
- */
813
- // Exported for reuse by the pi-native engine, which builds the same IM
814
- // approval-card escalation around its own approval gate.
815
170
  export function buildNativeAskUser(opts) {
816
171
  const baseCtx = {
817
172
  platform: opts.platform,
@@ -821,7 +176,7 @@ export function buildNativeAskUser(opts) {
821
176
  callerAgent: 'native',
822
177
  };
823
178
  return async (call) => {
824
- const reqId = _nativeRandomUUID();
179
+ const reqId = randomUUID();
825
180
  return await new Promise((resolve, reject) => {
826
181
  void approvalBus.registerSyntheticPending({
827
182
  runId: opts.runId,
@@ -834,42 +189,13 @@ export function buildNativeAskUser(opts) {
834
189
  });
835
190
  };
836
191
  }
837
- function splitEnvList(raw) {
838
- if (!raw)
839
- return [];
840
- return raw.split(',').map((s) => s.trim()).filter(Boolean);
841
- }
842
- /** Are we configured AT ALL to make tool calls? Reflected in startup
843
- * log + onboarding hints so the operator notices when they ship
844
- * `agents:['native']` without configuring a backend. */
845
- function isConfigured() {
846
- return pickProvider() !== null;
847
- }
848
- /**
849
- * Extract media attachments from a prompt string. Messenger adapters
850
- * (telegram / wechat / discord / feishu / dingtalk) inline a marker like
851
- * `[图片附件:/abs/path/to/file.jpg]` or `[image attachment: ...]` when a
852
- * user message carries a media payload. The path is already downloaded
853
- * to `~/.agim/media/<platform>/...` by the adapter, so we only need to
854
- * surface it to vision-capable providers; non-vision providers see the
855
- * original text and can still acknowledge the attachment.
856
- *
857
- * Heuristic-safe: matches absolute paths under common image extensions
858
- * + a path-safety check that the file exists. Anything ambiguous (no
859
- * extension, file missing, points outside ~/.agim/media) is silently
860
- * skipped so we never leak random filesystem paths into a vision call.
861
- */
862
192
  export function parsePromptMedia(prompt) {
863
193
  if (!prompt)
864
194
  return [];
865
- // Match both Chinese 图片附件 marker and English "image attachment".
866
- // Capture group is the path between `:`/`: ` and `]`.
867
195
  const re = /\[(?:图片附件|image attachment)[::]\s*([^\]]+)\]/g;
868
196
  const out = [];
869
197
  const home = process.env.HOME || '/root';
870
198
  const mediaRootRaw = process.env.IMHUB_MEDIA_ROOT || `${home}/.agim/media`;
871
- // Normalise media root once so the prefix check works regardless of
872
- // trailing slashes / dotted segments in the env override.
873
199
  const mediaRoot = pathResolve(mediaRootRaw);
874
200
  const mediaPrefix = mediaRoot.endsWith(pathSep) ? mediaRoot : mediaRoot + pathSep;
875
201
  for (;;) {
@@ -877,13 +203,7 @@ export function parsePromptMedia(prompt) {
877
203
  if (m === null)
878
204
  break;
879
205
  const rawPath = (m[1] || '').trim();
880
- if (!rawPath)
881
- continue;
882
- // Path-safety: must be absolute AND resolve to a real file UNDER
883
- // the media root. Use path.resolve + sep-aware prefix check so
884
- // siblings like `/root/.agim/media-evil/x` and traversals like
885
- // `/root/.agim/media/../etc/x` are rejected.
886
- if (!rawPath.startsWith('/'))
206
+ if (!rawPath || !rawPath.startsWith('/'))
887
207
  continue;
888
208
  const normalised = pathResolve(rawPath);
889
209
  if (normalised !== mediaRoot && !normalised.startsWith(mediaPrefix))
@@ -897,582 +217,12 @@ export function parsePromptMedia(prompt) {
897
217
  catch {
898
218
  continue;
899
219
  }
900
- // Image-only for v1; ignore other extensions to keep providers happy.
901
- const lower = normalised.toLowerCase();
902
- if (!/\.(jpg|jpeg|png|webp|gif)$/.test(lower))
220
+ if (!/\.(jpg|jpeg|png|webp|gif)$/i.test(normalised))
903
221
  continue;
904
222
  out.push({ path: normalised });
905
223
  }
906
224
  return out;
907
225
  }
908
- /**
909
- * Tool-call heartbeat helper. Solves the "agim 没反应" anxiety during
910
- * long tool calls (research-shaped tools / call_agent A2A turns can
911
- * run > 10s under the 30-minute hard timeout).
912
- *
913
- * Behaviour:
914
- * - When a tool call's onToolStart fires, schedule a one-shot push
915
- * to the IM thread after IMHUB_NATIVE_HEARTBEAT_MS (default 6000).
916
- * - When the call's onToolEnd fires first, cancel the pending push.
917
- * - shutdown() clears any still-pending timers (turn finished cleanly).
918
- *
919
- * Suppression: handlePushOp uses the standard notification-evaluator
920
- * gate + the per-user rate limit (10/min default). The heartbeat is
921
- * intentionally short ("🔧 调用工具 X 中…") so the gate's "low signal"
922
- * rule rarely drops it; if operators want them muted entirely, set
923
- * IMHUB_NATIVE_HEARTBEAT_MS=0.
924
- */
925
- function buildHeartbeats(runCtx) {
926
- // Tool-level pulse: tools that take longer than IMHUB_NATIVE_HEARTBEAT_MS
927
- // (default 6000) push a one-shot "🔧 调用工具 X 中…" so the user knows
928
- // why the bot has gone quiet. Cancelled on tool end. Set 0 to disable.
929
- const rawDelay = parseInt(process.env.IMHUB_NATIVE_HEARTBEAT_MS || '6000', 10);
930
- const toolDelayMs = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 0;
931
- // Turn-level pulse: every IMHUB_NATIVE_TURN_HEARTBEAT_MS (default 180_000
932
- // = 3 min) since turn start, push a "⏳ 还在处理(已 Nm)..." so a multi-
933
- // step research turn that runs many sub-agents reassures the user it's
934
- // still alive even between tool calls. Set 0 to disable. The first
935
- // pulse fires after delayMs, NOT immediately, so short turns stay silent.
936
- const rawTurnMs = parseInt(process.env.IMHUB_NATIVE_TURN_HEARTBEAT_MS || '180000', 10);
937
- const turnDelayMs = Number.isFinite(rawTurnMs) && rawTurnMs > 0 ? rawTurnMs : 0;
938
- const pending = new Map();
939
- const startedAt = Date.now();
940
- let turnTimer = null;
941
- if (turnDelayMs > 0) {
942
- turnTimer = setInterval(() => {
943
- const elapsedMin = Math.round((Date.now() - startedAt) / 60_000);
944
- void handlePushOp({ text: `⏳ 还在处理(已 ${elapsedMin}m)…` }, runCtx).catch(() => { });
945
- }, turnDelayMs);
946
- }
947
- const shutdown = () => {
948
- for (const t of pending.values())
949
- clearTimeout(t);
950
- pending.clear();
951
- if (turnTimer) {
952
- clearInterval(turnTimer);
953
- turnTimer = null;
954
- }
955
- };
956
- const noop = () => { };
957
- if (toolDelayMs === 0)
958
- return { hooks: { onToolStart: noop, onToolEnd: noop }, shutdown };
959
- return {
960
- hooks: {
961
- onToolStart(call) {
962
- const timer = setTimeout(() => {
963
- void handlePushOp({ text: `🔧 调用工具 \`${call.name}\` 中…(已 ${Math.round(toolDelayMs / 1000)}s)` }, runCtx).catch(() => { });
964
- pending.delete(call.id);
965
- }, toolDelayMs);
966
- pending.set(call.id, timer);
967
- },
968
- onToolEnd(call) {
969
- const t = pending.get(call.id);
970
- if (t) {
971
- clearTimeout(t);
972
- pending.delete(call.id);
973
- }
974
- },
975
- },
976
- shutdown,
977
- };
978
- }
979
- // ─── AgentAdapter implementation ─────────────────────────────────────
980
- class NativeAgentAdapter {
981
- name = 'native';
982
- aliases = ['agim', 'llm', 'native-llm', 'na'];
983
- kind = 'in-process';
984
- /** One-line UI hint surfaced by `/agents`: which LLM role + backend
985
- * currently powers this adapter. Returns undefined when not
986
- * configured (caller renders 'NOT CONFIGURED' elsewhere). */
987
- describe() {
988
- const picked = pickProvider();
989
- if (!picked)
990
- return undefined;
991
- // 'role -> vendor:backend' so a glance at /agents tells you which
992
- // LLM is wired without opening config.json.
993
- return `role=${picked.role} -> ${picked.provider.providerType}:${picked.provider.name}`;
994
- }
995
- async isAvailable() {
996
- return isConfigured();
997
- }
998
- async *sendPrompt(sessionId, prompt, history = [], opts = {}) {
999
- const picked = pickProvider();
1000
- if (!picked) {
1001
- const msg = '❌ Agim Agent: no usable LLM backend configured. Add one in /settings/llm (or ~/.agim/config.json llmBackends + API key). Role bindings are optional.';
1002
- yield msg;
1003
- return;
1004
- }
1005
- const { provider, role } = picked;
1006
- // Build the message array. The native loop accepts a system message
1007
- // either as the first element OR via systemPrompt; we go with the
1008
- // dedicated field so user-supplied history doesn't get clipped by
1009
- // a synthetic system message.
1010
- const messages = [];
1011
- for (const m of history) {
1012
- // ChatMessage role is 'user' | 'assistant'; both map straight
1013
- // through. Skip empty content to keep prompts compact.
1014
- if (!m.content)
1015
- continue;
1016
- messages.push({ role: m.role, content: m.content });
1017
- }
1018
- // Parse `[图片附件:/path/to/file]` / `[image attachment: ...]` markers
1019
- // that messenger adapters inline into the prompt. Vision-capable
1020
- // providers will encode them as image_url blocks; others just see
1021
- // the original text and the model can acknowledge "an image was
1022
- // attached at <path>" without inspecting bytes.
1023
- const userMedia = parsePromptMedia(prompt);
1024
- messages.push({ role: 'user', content: prompt, ...(userMedia.length > 0 ? { media: userMedia } : {}) });
1025
- // Auto-compact long chats before the provider call. Op-out via
1026
- // IMHUB_NATIVE_COMPACT_TRIGGER_CHARS=0. Failure mode is no-op.
1027
- const compact = await maybeCompactHistory(messages);
1028
- const effectiveMessages = compact.messages;
1029
- if (compact.compacted) {
1030
- log.info({
1031
- event: 'native.compact.applied',
1032
- sessionId,
1033
- originalChars: compact.originalChars,
1034
- summaryChars: compact.summaryChars,
1035
- collapsedCount: compact.collapsedCount,
1036
- });
1037
- }
1038
- // Compose dispatcher: built-in first (so a stray MCP server with
1039
- // a colliding tool name doesn't shadow `native_now` etc.), then
1040
- // imhub built-ins (skills / memo / push / ask_user / call_agent),
1041
- // then external MCP. v1.2.47 added the imhub layer so native sees
1042
- // exactly the same mcp__imhub__* surface claude-code does via the
1043
- // MCP sidecar.
1044
- // v1.2.120 — compute the per-thread composite key once and thread
1045
- // it through PlanMode + todo-state resolution. When the call has
1046
- // no IM context (CLI / smoke), threadKey stays undefined → env-only
1047
- // / synthetic-key fallback behaviour.
1048
- const planThreadKey = (opts.platform && opts.threadId)
1049
- ? makeThreadKey(opts.platform, opts.channelId ?? '', opts.threadId)
1050
- : undefined;
1051
- const imhubCtx = {
1052
- platform: opts.platform || 'native-agent',
1053
- channelId: opts.channelId || 'default',
1054
- threadId: opts.threadId || sessionId,
1055
- userId: opts.userId || 'unknown',
1056
- callerAgent: 'native',
1057
- callerDepth: opts.callDepth ?? 0,
1058
- // Link A2A callee rows to the parent turn so the web A2A views (which
1059
- // filter parent_id IS NOT NULL) can see native-originated A2A.
1060
- ...(typeof opts.parentJobId === 'number' ? { parentJobId: opts.parentJobId } : {}),
1061
- // v1.2.139 — propagate parent's plan-mode to the imhub dispatcher
1062
- // so any call_agent invocation it makes hands the flag to the
1063
- // sub-agent (claude-code / opencode / codex / native). Without
1064
- // this, a parent in `/plan on` could delegate a write task and
1065
- // the child would happily run with full write access. Falls back
1066
- // to opts.planMode for A2A-initiated runs (sub-agent itself is
1067
- // already in plan mode).
1068
- callerPlanMode: effectivePlanModeOn(planThreadKey) || (opts.planMode === true),
1069
- };
1070
- // Resolve cwd here so fs-dispatcher can constrain reads/writes to
1071
- // the per-thread workspace subtree. Was previously resolved AFTER
1072
- // dispatch composition; moved up so fs tools see the right root.
1073
- const cwd = resolveAgentCwd('native', opts) || defaultAgentCwd('native');
1074
- // T2 (single tool registry): assemble the advertised tool list, the
1075
- // dispatch chain, AND the per-call concurrency classifier from ONE
1076
- // source-of-truth (see tool-registry.ts). This replaced three
1077
- // hand-maintained parallel lists (tools[] / combineDispatchers / the
1078
- // static parallelSafeTools Set) that silently drifted when a tool was
1079
- // added. The plan-exit dispatcher is always wired (it self-refuses off
1080
- // plan mode); its tool is advertised only when plan mode is on.
1081
- const assembled = assembleNativeTools({
1082
- cwd,
1083
- rpcCtx: opts.platform && opts.threadId
1084
- ? {
1085
- platform: opts.platform,
1086
- channelId: opts.channelId ?? '',
1087
- threadId: opts.threadId,
1088
- userId: opts.userId ?? '',
1089
- }
1090
- : undefined,
1091
- todoThreadKey: planThreadKey ?? `native:${sessionId}`,
1092
- planExitCtx: {
1093
- threadKey: planThreadKey ?? `native:${sessionId}`,
1094
- runId: sessionId,
1095
- platform: opts.platform,
1096
- channelId: opts.channelId,
1097
- threadId: opts.threadId,
1098
- userId: opts.userId,
1099
- },
1100
- advertisePlanExit: !!(planThreadKey && effectivePlanModeOn(planThreadKey)),
1101
- imhubCtx,
1102
- });
1103
- const tools = assembled.tools;
1104
- const dispatch = assembled.dispatch;
1105
- const policy = resolvePolicy(planThreadKey);
1106
- // v1.2.60 — when the policy would silently deny a tool call,
1107
- // escalate to the user via an IM approval card instead. Only
1108
- // wires when we have an actual IM thread (platform + threadId);
1109
- // CI / smoke-test runs without IM stay silent-deny so they don't
1110
- // hang awaiting a notifier that doesn't exist.
1111
- const askUser = (opts.platform && opts.threadId && approvalBus.hasNotifier())
1112
- ? buildNativeAskUser({
1113
- runId: sessionId,
1114
- platform: opts.platform,
1115
- channelId: opts.channelId ?? 'default',
1116
- threadId: opts.threadId,
1117
- userId: opts.userId ?? 'unknown',
1118
- })
1119
- : undefined;
1120
- const approve = buildPolicyApprovalGate({ ...policy, askUser });
1121
- const startedAt = Date.now();
1122
- log.info({
1123
- event: 'native.turn.start',
1124
- sessionId,
1125
- role,
1126
- backend: provider.name,
1127
- policy: describePolicy(policy),
1128
- tools: tools.length,
1129
- platform: opts.platform,
1130
- threadId: opts.threadId,
1131
- });
1132
- // Note: cwd is resolved above (before dispatch composition) so fs
1133
- // tools can constrain reads/writes to the per-thread workspace.
1134
- // Was previously resolved here — that was fine before native had
1135
- // fs tools, but fs-dispatcher now needs the value at dispatch
1136
- // build time.
1137
- // ADR-0002 — prefer the inbound turn's trace id (plumbed via opts.traceId
1138
- // from the router) so the native iteration / turn audit rows correlate
1139
- // back to the originating IM message in SIEM joins. Fall back to a
1140
- // self-minted id only for entry points that don't carry one (e.g. an A2A
1141
- // in-process spawn or a direct CLI/smoke invocation).
1142
- const traceId = opts.traceId || `native-${sessionId}-${Date.now()}`;
1143
- // Wall-clock cap for the agent loop. agim's IM-layer enforces a 30-
1144
- // minute hard ceiling per turn (DEFAULT_TIMEOUT_MS in agent-base.ts);
1145
- // we set the inner loop a hair below so the loop's own abort fires
1146
- // with a clean `finishReason='aborted'` BEFORE the outer SIGTERM. The
1147
- // agent-loop default is a conservative 5 minutes which is far too
1148
- // short for native turns that orchestrate sub-agents via call_agent
1149
- // (each hop can run 1-3 minutes); without this override a multi-step
1150
- // research turn would abort mid-flight even with sub-tasks healthy.
1151
- // Operator can override via IMHUB_NATIVE_AGENT_TIMEOUT_MS.
1152
- const nativeTimeoutMs = (() => {
1153
- const raw = parseInt(process.env.IMHUB_NATIVE_AGENT_TIMEOUT_MS || '', 10);
1154
- if (Number.isFinite(raw) && raw > 0)
1155
- return raw;
1156
- return 28 * 60 * 1000; // 28 min — leaves 2 min of IM-layer headroom
1157
- })();
1158
- const heartbeats = buildHeartbeats(imhubCtx);
1159
- const chain = pickProviderChain();
1160
- // chain always starts with `picked` from above — index 0 is the
1161
- // primary; rest are fallbacks. We walk the chain only when the
1162
- // PREVIOUS attempt ended with a transient provider error and the
1163
- // turn produced no assistant text yet (so retrying is safe — no
1164
- // duplicate replies).
1165
- let result = null;
1166
- let usedRole = role;
1167
- let usedProvider = provider;
1168
- for (let i = 0; i < chain.length; i++) {
1169
- const candidate = chain[i];
1170
- usedProvider = candidate.provider;
1171
- usedRole = candidate.role;
1172
- result = await runAgentLoop({
1173
- provider: candidate.provider,
1174
- systemPrompt: buildSystemPrompt(candidate.provider, candidate.role, cwd, planThreadKey),
1175
- messages: effectiveMessages,
1176
- tools,
1177
- dispatch,
1178
- approve,
1179
- callOptions: { model: opts.model },
1180
- // v1.2.92 — actually honour IMHUB_NATIVE_AGENT_MAX_ITER (the
1181
- // banner has been advising operators to set it since v1.2.48
1182
- // but the reader didn't exist; default stayed at 20).
1183
- maxIterations: resolveMaxIterations(),
1184
- // v1.2.98 — goal-critic anchor. agent-loop runs the critic
1185
- // periodically; if it judges the recent tool chain off-track
1186
- // we get finishReason='off_track' and render a redirect recap.
1187
- // Pulls the active long-task goal lazily; failures are silent
1188
- // (critic just won't have the goal anchor, will fall back to
1189
- // the prompt). The critic itself is disabled when
1190
- // IMHUB_NATIVE_CRITIC=off or no `cheap` role is configured.
1191
- criticAnchor: await (async () => {
1192
- let goalTitle;
1193
- let goalBody;
1194
- if (opts.platform && opts.threadId) {
1195
- try {
1196
- const { getActiveGoal } = await import('../../../core/goals.js');
1197
- const g = getActiveGoal(opts.platform, opts.channelId ?? '', opts.threadId);
1198
- if (g) {
1199
- goalTitle = g.title;
1200
- goalBody = g.body ?? undefined;
1201
- }
1202
- }
1203
- catch { /* best-effort */ }
1204
- }
1205
- return { prompt, goalTitle, goalBody };
1206
- })(),
1207
- timeoutMs: nativeTimeoutMs,
1208
- signal: opts.signal,
1209
- audit: {
1210
- agent: `llm:${candidate.provider.name}`,
1211
- intent: 'native.agent.iter',
1212
- userId: opts.userId,
1213
- platform: opts.platform || 'native-agent',
1214
- traceId,
1215
- },
1216
- hooks: heartbeats.hooks,
1217
- // v1.2.109 / T2 — declare read-only / pure tools parallel-safe so
1218
- // multi-call iterations (e.g. "read these 3 files") run
1219
- // concurrently. Now a PER-CALL classifier owned by the tool
1220
- // registry (fail-closed: unknown / throwing → serial), replacing
1221
- // the static per-name set.
1222
- parallelSafeClassifier: assembled.isParallelSafe,
1223
- // v1.2.112 — stream provider responses so partial assistant
1224
- // text survives the IM 30-min hard timeout. Env-gated kill
1225
- // switch (`IMHUB_NATIVE_STREAM_PARTIAL=off` + global
1226
- // `IMHUB_AGENT_LOOP_STREAM=off`) for safety. No onPartialText
1227
- // wired yet — that lands when we push streaming to the IM
1228
- // client. The accumulation itself already saves the partial.
1229
- streamPartialText: resolveNativeStreamPartial(),
1230
- });
1231
- if (result.finishReason !== 'error')
1232
- break;
1233
- const errStr = String(result.error || '');
1234
- const isTransient = /5\d\d|timeout|ECONN|ETIMEDOUT|fetch failed|socket hang up|408|network/i.test(errStr);
1235
- const hasText = result.text && result.text.length > 0;
1236
- if (!isTransient || hasText || i === chain.length - 1)
1237
- break;
1238
- log.warn({
1239
- event: 'native.fallback.next',
1240
- from: candidate.provider.name,
1241
- nextIdx: i + 1,
1242
- err: errStr,
1243
- }, `provider ${candidate.provider.name} transient-failed; trying next fallback`);
1244
- }
1245
- heartbeats.shutdown();
1246
- if (!result) {
1247
- // Shouldn't happen — pickProvider above already returned the primary
1248
- // so the chain has at least one entry. Defensive belt-and-suspenders.
1249
- yield '❌ Agim Agent: provider chain ended without any attempt';
1250
- return;
1251
- }
1252
- // v1.2.142 — Stage report. Replaces v1.2.94 auto-summary.
1253
- //
1254
- // Any unhappy turn ending (empty / max_iter / stuck_loop / off_track)
1255
- // first tries to produce a *user-facing stage report* with the
1256
- // current provider — based on what got done, what failed, and what
1257
- // to do next. The technical "✓ tool ×N / ✗ tool ×M" recap from
1258
- // v1.2.91 is kept ONLY as a last-resort fallback when the stage
1259
- // report itself fails or comes back empty.
1260
- //
1261
- // Why the change: the operator-facing message previously dumped
1262
- // tool-name counts, which is debugger fodder, not a deliverable.
1263
- // Even a half-failed turn has a real *intermediate result* the
1264
- // user can act on — the model just needs to be asked the right
1265
- // question. See `tryStageReport` for the prompt.
1266
- //
1267
- // The empty/stop branch's logic now lives inside `tryStageReport`
1268
- // — empty `result.text` is one of four kinds it covers, not a
1269
- // special case.
1270
- // Per-turn parent audit row that aggregates the iteration rows
1271
- // already written by runAgentLoop. Lets /tasks#cost sum cost per
1272
- // turn rather than per iteration.
1273
- //
1274
- // NOTE — moved BELOW the body-assembly block in v1.2.142 so the
1275
- // stage-report retry cost is counted in this row. (Was above when
1276
- // auto-summary mutated `result.usage` in place; the new flow
1277
- // returns a separate `extraCost` from tryStageReport instead, so
1278
- // we wait until after body assembly to log.)
1279
- // ─── body assembly ───────────────────────────────────────────────
1280
- //
1281
- // Compose the user-facing reply. v1.2.142 reshuffles the order:
1282
- // each unhappy branch FIRST tries `tryStageReport` (a natural-
1283
- // language stage report based on what got done + what failed +
1284
- // what to do next). Technical `composeUnfinishedTurnRecap` is
1285
- // demoted to last-resort fallback when the stage report itself
1286
- // fails / returns empty.
1287
- let body = result.text;
1288
- /** Extra cost from the stage-report retry (when it runs). Added to
1289
- * the audit row + opts.onUsage below so /tasks#cost stays
1290
- * accurate. */
1291
- let stageReportCost = 0;
1292
- const maxIter = resolveMaxIterations();
1293
- /** Local helper — log the unhappy branch + try stage report.
1294
- * Returns the stage-report text on success; null when caller
1295
- * should fall back to its technical recap. */
1296
- const stageOrFallback = async (kind, offTrackReason) => {
1297
- const stage = await tryStageReport({
1298
- prompt,
1299
- result,
1300
- provider: usedProvider,
1301
- kind,
1302
- offTrackReason,
1303
- model: opts.model,
1304
- signal: opts.signal,
1305
- sessionId,
1306
- });
1307
- if (stage) {
1308
- stageReportCost += stage.costUsd ?? 0;
1309
- return stage.text;
1310
- }
1311
- return null;
1312
- };
1313
- if (result.finishReason === 'error') {
1314
- body = `❌ Agim Agent error: ${result.error ?? '(no detail)'}`;
1315
- }
1316
- else if (result.finishReason === 'max_iterations') {
1317
- log.warn({
1318
- event: 'native.turn.max_iterations',
1319
- sessionId,
1320
- backend: usedProvider.name,
1321
- role: usedRole,
1322
- iterations: result.iterations,
1323
- maxIter,
1324
- toolCallCount: result.toolCalls.length,
1325
- lastToolName: result.toolCalls.length > 0
1326
- ? result.toolCalls[result.toolCalls.length - 1]?.name ?? null
1327
- : null,
1328
- elapsedMs: Date.now() - startedAt,
1329
- }, `native turn hit max iterations cap (${result.iterations}/${maxIter}, tools=${result.toolCalls.length})`);
1330
- body = (await stageOrFallback('max_iter'))
1331
- ?? composeUnfinishedTurnRecap(result, 'max_iter', maxIter);
1332
- }
1333
- else if (result.finishReason === 'off_track') {
1334
- // v1.2.98 — goal-critic flagged the recent tool chain as
1335
- // semantically off-target. result.error carries
1336
- // "<reason> || redirect: <suggestion>" (or just <reason> when
1337
- // the critic had no redirect to offer). We split it back and
1338
- // surface a recap that names the suspected drift + suggestion.
1339
- const blob = String(result.error ?? '');
1340
- const [reason, ...rest] = blob.split('|| redirect: ');
1341
- const redirect = rest.join('|| redirect: ').trim();
1342
- log.warn({
1343
- event: 'native.turn.off_track',
1344
- sessionId,
1345
- backend: usedProvider.name,
1346
- role: usedRole,
1347
- iterations: result.iterations,
1348
- toolCallCount: result.toolCalls.length,
1349
- reason: reason.trim(),
1350
- redirect: redirect || null,
1351
- elapsedMs: Date.now() - startedAt,
1352
- }, `goal-critic flagged turn as off-track: ${reason.trim()}`);
1353
- const offTrackReason = reason.trim() + (redirect ? `;建议方向:${redirect}` : '');
1354
- body = (await stageOrFallback('off_track', offTrackReason))
1355
- ?? composeOffTrackRecap(result, reason.trim(), redirect);
1356
- }
1357
- else if (result.finishReason === 'stuck_loop') {
1358
- log.warn({
1359
- event: 'native.turn.stuck_loop',
1360
- sessionId,
1361
- backend: usedProvider.name,
1362
- role: usedRole,
1363
- iterations: result.iterations,
1364
- toolCallCount: result.toolCalls.length,
1365
- lastToolName: result.toolCalls.length > 0
1366
- ? result.toolCalls[result.toolCalls.length - 1]?.name ?? null
1367
- : null,
1368
- elapsedMs: Date.now() - startedAt,
1369
- }, `native turn stopped early — stuck loop after ${result.iterations} iter (tools=${result.toolCalls.length})`);
1370
- body = (await stageOrFallback('stuck_loop'))
1371
- ?? composeUnfinishedTurnRecap(result, 'stuck_loop', maxIter);
1372
- }
1373
- else if (result.finishReason === 'aborted') {
1374
- body = '⏹ Agim Agent aborted before completion.';
1375
- }
1376
- else if (result.finishReason === 'hallucinated_tools') {
1377
- // v1.2.147 — agent-loop detected the model narrated a tool
1378
- // invocation ("我现在调用 native_write_file: ```python …```")
1379
- // without actually emitting toolCalls. Surface a recap that
1380
- // names the failure mode + suggests a backend switch, instead
1381
- // of shipping the lie as a normal reply.
1382
- log.warn({
1383
- event: 'native.turn.hallucinated_tools',
1384
- sessionId,
1385
- backend: usedProvider.name,
1386
- role: usedRole,
1387
- iterations: result.iterations,
1388
- toolCallCount: result.toolCalls.length,
1389
- textLen: (result.text || '').length,
1390
- elapsedMs: Date.now() - startedAt,
1391
- }, `native turn ended with hallucinated tool-call narration (no real toolCalls emitted)`);
1392
- body = composeHallucinatedToolRecap(result, usedProvider.name);
1393
- }
1394
- else if (!body) {
1395
- // Normal `stop` finish but the model didn't write anything. Most
1396
- // often the model completed a tool chain and forgot to close
1397
- // (or the chain failed badly enough that it gave up). Stage
1398
- // report turns either case into a useful user-facing summary.
1399
- const lastCall = result.toolCalls.length > 0
1400
- ? result.toolCalls[result.toolCalls.length - 1]
1401
- : null;
1402
- log.warn({
1403
- event: 'native.turn.empty_response',
1404
- sessionId,
1405
- backend: usedProvider.name,
1406
- role: usedRole,
1407
- finishReason: result.finishReason,
1408
- iterations: result.iterations,
1409
- toolCallCount: result.toolCalls.length,
1410
- lastToolName: lastCall?.name ?? null,
1411
- lastToolError: lastCall?.isError ?? null,
1412
- lastToolPreview: lastCall?.preview?.slice(0, 200) ?? null,
1413
- elapsedMs: Date.now() - startedAt,
1414
- }, `native turn ended with empty text (finishReason=${result.finishReason}, ` +
1415
- `iterations=${result.iterations}, tools=${result.toolCalls.length})`);
1416
- // Stage report only runs when there ARE tool calls to summarise;
1417
- // an empty turn with zero tools means the model literally said
1418
- // nothing — recap's "no tools called" branch handles that case.
1419
- if (result.toolCalls.length > 0) {
1420
- body = (await stageOrFallback('empty'))
1421
- ?? composeUnfinishedTurnRecap(result, 'empty', maxIter);
1422
- }
1423
- else {
1424
- body = composeUnfinishedTurnRecap(result, 'empty', maxIter);
1425
- }
1426
- }
1427
- // ─── audit + usage (v1.2.142 moved below body assembly) ──────────
1428
- // Per-turn parent audit row that aggregates the iteration rows
1429
- // already written by runAgentLoop. `stageReportCost` covers any
1430
- // extra LLM call we made while composing the user-facing message.
1431
- const turnCostUsd = (typeof result.usage.costUsd === 'number' ? result.usage.costUsd : 0)
1432
- + stageReportCost;
1433
- try {
1434
- logInvocation({
1435
- traceId,
1436
- userId: opts.userId ?? '',
1437
- platform: opts.platform || 'native-agent',
1438
- agent: this.name,
1439
- intent: 'native.agent.turn',
1440
- promptLen: prompt.length,
1441
- responseLen: body.length,
1442
- durationMs: Date.now() - startedAt,
1443
- cost: turnCostUsd,
1444
- success: result.finishReason !== 'error' && result.finishReason !== 'aborted',
1445
- error: result.error,
1446
- });
1447
- }
1448
- catch { /* audit best-effort */ }
1449
- // Surface usage to cli's per-session accumulator the same way CLI
1450
- // adapters do (via opts.onUsage).
1451
- if (opts.onUsage && turnCostUsd > 0) {
1452
- try {
1453
- opts.onUsage({ costUsd: turnCostUsd });
1454
- }
1455
- catch { /* best-effort */ }
1456
- }
1457
- log.info({
1458
- event: 'native.turn.done',
1459
- sessionId,
1460
- backend: usedProvider.name,
1461
- role: usedRole,
1462
- fellBack: usedProvider.name !== provider.name,
1463
- finishReason: result.finishReason,
1464
- iterations: result.iterations,
1465
- toolCalls: result.toolCalls.length,
1466
- stageReportCostUsd: stageReportCost > 0 ? stageReportCost : null,
1467
- elapsedMs: Date.now() - startedAt,
1468
- });
1469
- yield body;
1470
- }
1471
- }
1472
- export const nativeAgentAdapter = new NativeAgentAdapter();
1473
- /** Lightweight banner for cli.ts boot log. Lets operators see at a
1474
- * glance whether `/cc native` will work before they try it in an IM
1475
- * thread. */
1476
226
  export function describeNativeAgent() {
1477
227
  const picked = pickProvider();
1478
228
  if (!picked)