@hoplogic/hopjit 0.1.0

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/act-body-interpreter.d.ts +24 -0
  4. package/dist/act-body-interpreter.js +159 -0
  5. package/dist/act-body-parser.d.ts +10 -0
  6. package/dist/act-body-parser.js +577 -0
  7. package/dist/act-builtins.d.ts +12 -0
  8. package/dist/act-builtins.js +104 -0
  9. package/dist/ast-helpers.d.ts +26 -0
  10. package/dist/ast-helpers.js +58 -0
  11. package/dist/ast-runtime.d.ts +49 -0
  12. package/dist/ast-runtime.js +224 -0
  13. package/dist/ast-types.d.ts +260 -0
  14. package/dist/ast-types.js +4 -0
  15. package/dist/cli-types.d.ts +190 -0
  16. package/dist/cli-types.js +4 -0
  17. package/dist/cli.d.ts +26 -0
  18. package/dist/cli.js +623 -0
  19. package/dist/dispatcher.d.ts +92 -0
  20. package/dist/dispatcher.js +692 -0
  21. package/dist/doc-ref.d.ts +41 -0
  22. package/dist/doc-ref.js +214 -0
  23. package/dist/engine-traverse.d.ts +37 -0
  24. package/dist/engine-traverse.js +385 -0
  25. package/dist/engine.d.ts +141 -0
  26. package/dist/engine.js +1792 -0
  27. package/dist/errors.d.ts +47 -0
  28. package/dist/errors.js +34 -0
  29. package/dist/hoplog.d.ts +120 -0
  30. package/dist/hoplog.js +501 -0
  31. package/dist/parser.d.ts +7 -0
  32. package/dist/parser.js +802 -0
  33. package/dist/persistence.d.ts +60 -0
  34. package/dist/persistence.js +208 -0
  35. package/dist/prompt.d.ts +130 -0
  36. package/dist/prompt.js +1014 -0
  37. package/dist/provider-types.d.ts +134 -0
  38. package/dist/provider-types.js +3 -0
  39. package/dist/runtime-types.d.ts +84 -0
  40. package/dist/runtime-types.js +4 -0
  41. package/dist/tools.d.ts +16 -0
  42. package/dist/tools.js +141 -0
  43. package/dist/validator.d.ts +23 -0
  44. package/dist/validator.js +959 -0
  45. package/driver/codex/AGENTS.md +54 -0
  46. package/driver/hopskill-build/SKILL.md +137 -0
  47. package/driver/hopskill-build/references/spec-skeleton.md +132 -0
  48. package/driver/hopskill-build/references/step-type-cheatsheet.md +56 -0
  49. package/driver/hopskill-build/references/three-focus-rules.md +148 -0
  50. package/driver/hopspec-skill.md +187 -0
  51. package/driver/references/cli-discovery.md +36 -0
  52. package/driver/references/discovery.md +45 -0
  53. package/driver/references/driver-subagent.md +50 -0
  54. package/driver/references/parallel-worker.md +50 -0
  55. package/driver/references/step-execution-rules.md +47 -0
  56. package/package.json +52 -0
@@ -0,0 +1,692 @@
1
+ // @module: step-dispatcher ^anc-struct-step-dispatcher
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+ import { readFileSync, existsSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { PromptAssembler, injectKnowledgeContext } from './prompt.js';
7
+ import { DefaultToolProvider } from './tools.js';
8
+ import { BodyInterpreter } from './act-body-interpreter.js';
9
+ import { ErrorCode } from './errors.js';
10
+ // 算子级重试:SCHEMA_MISMATCH 缺省重做次数(与 subtask retry 缺省值对齐)
11
+ // 见 design/step-dispatcher.md ^anc-exec-operator-retry
12
+ const SCHEMA_RETRY_MAX = 3;
13
+ // API 输出 token 上限缺省值(env / resource_limits 都未指定时)。
14
+ const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
15
+ /** 独立模式的驱动适配层:跑调度循环(init→next→execute→done)并直调 Anthropic API 完成单步推理与工具调用。见 [[step-dispatcher#^anc-struct-step-dispatcher]] */
16
+ export class StepDispatcher {
17
+ engine;
18
+ hostConfig;
19
+ clients = new Map(); // @a: anc-exec-model-routing
20
+ defaultClient;
21
+ toolProvider;
22
+ cumulativeTokens = 0;
23
+ replanAttempts = new Map();
24
+ maxToolIterations;
25
+ tokenBudget;
26
+ timeoutSeconds;
27
+ pendingPause = null; // confirm/commit 暂停信号,executionLoop 读取后返回
28
+ constructor(engine, hostConfig, config = {}) {
29
+ this.engine = engine;
30
+ this.hostConfig = hostConfig;
31
+ const { apiKey, baseURL } = this.resolveCredential();
32
+ this.defaultClient = baseURL
33
+ ? new Anthropic({ apiKey, baseURL })
34
+ : new Anthropic({ apiKey });
35
+ this.clients.set('default', this.defaultClient);
36
+ this.toolProvider = hostConfig.tool_provider ?? new DefaultToolProvider(hostConfig);
37
+ this.maxToolIterations = config.maxToolIterations
38
+ ?? hostConfig.resource_limits?.max_tool_iterations ?? 20;
39
+ this.tokenBudget = config.tokenBudget;
40
+ this.timeoutSeconds = config.timeoutSeconds
41
+ ?? hostConfig.resource_limits?.timeout_seconds;
42
+ }
43
+ async runSpec() {
44
+ return this.executionLoop();
45
+ }
46
+ /**
47
+ * 同实例恢复:注入 caller 对 confirm 暂停步骤的决策,继续执行循环。 // @a: anc-exec-resume-semantics
48
+ * confirm 是唯一的暂停点(鉴权/决策环节):answer 作为步骤输出注入 → completeStep(audit hitl_decision)。
49
+ * commit 不暂停(授权在前序完成),故 resume 不处理 commit。
50
+ */
51
+ async resume(stepId, answer) {
52
+ const node = this.findStepInSpec(stepId);
53
+ if (!node) {
54
+ throw new Error(`resume: step ${stepId} not found`);
55
+ }
56
+ if (node.step_type !== 'confirm' && node.step_type !== 'ask') {
57
+ throw new Error(`resume: step ${stepId} is ${node.step_type}, not a pausable step (only confirm/ask pause)`);
58
+ }
59
+ // confirm/ask answer 规范化统一在 engine.completeStep(^anc-exec-confirm-answer / ^anc-exec-hitl-presentation,
60
+ // 模式无关)。dispatcher 仅透传 answer,不自行判定,避免两模式逻辑分叉。审计由 completeStep 记录。
61
+ this.engine.completeStep(stepId, answer);
62
+ return this.executionLoop();
63
+ }
64
+ async resumeSpec() {
65
+ return this.executionLoop();
66
+ }
67
+ getCumulativeTokens() {
68
+ return this.cumulativeTokens;
69
+ }
70
+ async executionLoop() {
71
+ while (true) {
72
+ const next = this.engine.nextStep();
73
+ switch (next.status) {
74
+ case 'completed':
75
+ return { status: 'completed', outputs: next.outputs, cumulative_tokens: this.cumulativeTokens };
76
+ case 'failed': {
77
+ const f = next;
78
+ return { status: 'failed', failure: { step_id: f.failed_step_id, reason: f.failure_reason }, cumulative_tokens: this.cumulativeTokens };
79
+ }
80
+ case 'paused':
81
+ return { status: 'paused', pause: next, cumulative_tokens: this.cumulativeTokens };
82
+ case 'adaptive_needed':
83
+ await this.handleAdaptive(next);
84
+ break;
85
+ case 'step_ready':
86
+ await this.handleStepReady(next);
87
+ // confirm/commit 步骤暂停:步骤保持 running,返回暂停信号等待 caller 决策(CITL)
88
+ if (this.pendingPause) {
89
+ const pause = this.pendingPause;
90
+ this.pendingPause = null;
91
+ return { status: 'paused', pause, cumulative_tokens: this.cumulativeTokens };
92
+ }
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ async handleStepReady(step) {
98
+ const hasNoneInputs = this.checkNoneInputs(step);
99
+ if (hasNoneInputs) {
100
+ this.engine.failStep(step.step_id, 'MISSING_INPUT: required input is None');
101
+ return;
102
+ }
103
+ // L2 Knowledge injection: enrich context if KnowledgeProvider is available
104
+ await this.injectKnowledge(step);
105
+ // 注:L2d doc-ref 解析已下沉到 engine.assembleBasicContext(两模式共享),
106
+ // 不在 dispatcher——见 design/step-dispatcher.md ^anc-exec-doc-ref-resolve(架构修正)
107
+ try {
108
+ let result = await this.executeStepWithTimeout(step);
109
+ if (result === 'paused')
110
+ return;
111
+ if (this.hasLackOfInfo(result) && this.hostConfig.knowledge_provider) {
112
+ const query = typeof result.lack_of_info === 'string' ? result.lack_of_info : step.summary;
113
+ const fragments = await this.hostConfig.knowledge_provider.retrieve(query, 5);
114
+ if (fragments.length > 0) {
115
+ const supplementary = fragments.map(f => f.content).join('\n\n');
116
+ step.context.knowledge_context = (step.context.knowledge_context ?? '')
117
+ + `\n\n### 补充检索(由步骤 ${step.step_id} 的 lack_of_info 触发)\n${supplementary}`;
118
+ const retryResult = await this.executeStepWithTimeout(step);
119
+ if (retryResult === 'paused')
120
+ return;
121
+ if (this.hasLackOfInfo(retryResult)) {
122
+ this.engine.failStep(step.step_id, `${query} (supplementary retrieval exhausted)`, 'lack_of_info');
123
+ return;
124
+ }
125
+ result = retryResult;
126
+ }
127
+ else {
128
+ this.engine.failStep(step.step_id, `${query} (no knowledge available)`, 'lack_of_info');
129
+ return;
130
+ }
131
+ }
132
+ // 算子级重试:completeStep 校验输出 schema,不匹配则拼反馈重做(缺省 3 次),
133
+ // 耗尽 failStep(转入容器级 retry)。复用模式无此循环——done 直接返回 SCHEMA_MISMATCH
134
+ // 由 caller 重试。见 design/step-dispatcher.md ^anc-exec-operator-retry
135
+ // @a: anc-exec-operator-retry
136
+ // body 步骤跳过 SCHEMA_MISMATCH 重试:该重试是为 LLM「改 prompt 重做」设计的,body 不是
137
+ // LLM 且确定性——同 body 重跑产出一样的不匹配,重做无意义。schema 不匹配直接 failStep
138
+ // 转容器级 retry。(注:工具瞬时失败的重试归 ToolProvider 内部,与本层无关。)// @a: anc-exec-act-body-interp
139
+ const bodyNode = this.findStepInSpec(step.step_id);
140
+ const isBodyStep = !!bodyNode?.body;
141
+ const baseInstruction = step.context.instruction;
142
+ let attempt = 1;
143
+ while (true) {
144
+ const resp = this.engine.completeStep(step.step_id, result);
145
+ if (resp.code !== ErrorCode.SCHEMA_MISMATCH)
146
+ break; // 通过或其他响应 → 交回主循环
147
+ if (isBodyStep || attempt >= SCHEMA_RETRY_MAX) {
148
+ this.engine.failStep(step.step_id, isBodyStep
149
+ ? `${resp.message}(act body 确定性,schema 不匹配不改 prompt 重做,转容器级 retry)`
150
+ : `${resp.message} (after ${SCHEMA_RETRY_MAX} attempts)`);
151
+ return;
152
+ }
153
+ attempt++;
154
+ // 把校验反馈拼回 instruction,重做该算子
155
+ step.context.instruction = `${baseInstruction}\n\n[上次输出未通过校验,请修正后重新输出]\n${resp.message}`;
156
+ const retry = await this.executeStepWithTimeout(step);
157
+ if (retry === 'paused')
158
+ return;
159
+ result = retry;
160
+ }
161
+ step.context.instruction = baseInstruction;
162
+ }
163
+ catch (err) {
164
+ const reason = err instanceof Error ? err.message : String(err);
165
+ this.engine.failStep(step.step_id, reason);
166
+ }
167
+ }
168
+ async injectKnowledge(step) {
169
+ const provider = this.hostConfig.knowledge_provider;
170
+ if (!provider)
171
+ return;
172
+ const spec = this.engine.getSpec();
173
+ if (!spec)
174
+ return;
175
+ const stepNode = this.findStepInSpec(step.step_id);
176
+ if (!stepNode)
177
+ return;
178
+ try {
179
+ const sources = await injectKnowledgeContext(step.context, provider, spec, stepNode);
180
+ // knowledge 流控字段(info 级):记录本步检索到的知识来源 // @a: anc-obs-step-mapping
181
+ if (sources.length > 0) {
182
+ const at = new Date().toISOString();
183
+ this.engine.getHopLog()?.recordStepMeta(step.step_id, {
184
+ knowledge: sources.map(source_id => ({ source_id, at })),
185
+ });
186
+ }
187
+ }
188
+ catch (err) {
189
+ // 知识注入降级:provider 抛错 → L2 留空。不静默——记 stderr 诊断,
190
+ // 让排障能区分「provider 真没返回」与「provider 抛异常」。
191
+ const reason = err instanceof Error ? err.message : String(err);
192
+ process.stderr.write(`[knowledge-degraded] step ${step.step_id}: provider error, L2 empty — ${reason}\n`);
193
+ }
194
+ }
195
+ async handleAdaptive(resp) {
196
+ const subtaskId = resp.subtask_id;
197
+ const attempts = this.replanAttempts.get(subtaskId) ?? 0;
198
+ const maxReplanAttempts = this.hostConfig.resource_limits?.max_replan_attempts ?? 3;
199
+ if (attempts >= maxReplanAttempts) {
200
+ this.engine.failStep(subtaskId, `Replan circuit breaker: ${maxReplanAttempts} attempts exhausted`);
201
+ return;
202
+ }
203
+ const hasKnowledge = !!this.hostConfig.knowledge_provider;
204
+ const assembler = new PromptAssembler(this.engine, hasKnowledge);
205
+ const ctx = assembler.assembleAdaptiveContext(subtaskId);
206
+ const { request, client } = this.buildApiRequest(ctx, 'reason');
207
+ try {
208
+ const response = await this.callLlmWithRetry(request, client);
209
+ const newStepsMd = this.extractTextContent(response);
210
+ const replanResult = this.engine.submitReplan(subtaskId, newStepsMd);
211
+ if (replanResult.status === 'error') {
212
+ this.recordReplanFailure(subtaskId, attempts, maxReplanAttempts, `Replan failed after ${maxReplanAttempts} attempts`);
213
+ }
214
+ else {
215
+ this.replanAttempts.set(subtaskId, 0);
216
+ }
217
+ }
218
+ catch (err) {
219
+ const reason = err instanceof Error ? err.message : String(err);
220
+ this.recordReplanFailure(subtaskId, attempts, maxReplanAttempts, `Replan API error after ${maxReplanAttempts} attempts: ${reason}`);
221
+ }
222
+ }
223
+ async executeStepWithTimeout(step) {
224
+ if (!this.timeoutSeconds) {
225
+ return this.executeStep(step);
226
+ }
227
+ const controller = new AbortController();
228
+ const timeout = setTimeout(() => controller.abort(), this.timeoutSeconds * 1000);
229
+ try {
230
+ const result = await Promise.race([
231
+ this.executeStep(step),
232
+ new Promise((_, reject) => {
233
+ controller.signal.addEventListener('abort', () => reject(new Error(`STEP_TIMEOUT: exceeded ${this.timeoutSeconds}s`)));
234
+ }),
235
+ ]);
236
+ return result;
237
+ }
238
+ finally {
239
+ clearTimeout(timeout);
240
+ }
241
+ }
242
+ async executeStep(step) {
243
+ const stepType = step.step_type;
244
+ switch (stepType) {
245
+ case 'reason':
246
+ case 'check':
247
+ return this.executeReasonOrCheck(step);
248
+ case 'act':
249
+ return this.executeActBody(step, false);
250
+ case 'confirm':
251
+ return this.executeConfirm(step);
252
+ case 'commit':
253
+ return this.executeCommit(step);
254
+ case 'call':
255
+ throw new Error('call step recursive execution not implemented in v1');
256
+ default:
257
+ throw new Error(`Unknown executable step type: ${stepType}`);
258
+ }
259
+ }
260
+ async executeReasonOrCheck(step) {
261
+ const { request, client } = this.buildApiRequest(step.context, step.step_type, step);
262
+ let response;
263
+ try {
264
+ response = await this.callLlmWithRetry(request, client);
265
+ }
266
+ catch (err) {
267
+ if (this.isContextOverflow(err)) {
268
+ const hasKnowledge = !!this.hostConfig.knowledge_provider;
269
+ const assembler = new PromptAssembler(this.engine, hasKnowledge);
270
+ const stepNode = this.findStepInSpec(step.step_id);
271
+ if (stepNode) {
272
+ const aggressiveCtx = assembler.reassembleAggressive(stepNode);
273
+ const { request: retryRequest, client: retryClient } = this.buildApiRequest(aggressiveCtx, step.step_type, step);
274
+ response = await this.callLlmWithRetry(retryRequest, retryClient);
275
+ }
276
+ else {
277
+ throw err;
278
+ }
279
+ }
280
+ else {
281
+ throw err;
282
+ }
283
+ }
284
+ const hopLog = this.engine.getHopLog();
285
+ if (hopLog) {
286
+ // 独立模式执行内幕(LLM/tool)由 Dispatcher 写入 HopLog;复用模式归 caller 生态 // @a: anc-obs-mode-boundary
287
+ hopLog.recordStepMeta(step.step_id, {
288
+ llm: { model: request.model, input_tokens: response.usage?.input_tokens, output_tokens: response.usage?.output_tokens },
289
+ tokens_used: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
290
+ });
291
+ }
292
+ return this.parseStepOutput(response, step.context.output_schema);
293
+ }
294
+ // act 步骤中 LLM 仅能从预注册工具列表选择调用——无任意代码执行 // @a: anc-exec-sandbox-principle, anc-config-sandbox-step-mapping
295
+ async executeActWithTools(step, allowCommit = false) {
296
+ const tools = this.toolProvider.list().map(t => ({
297
+ name: t.name,
298
+ description: t.description,
299
+ input_schema: t.input_schema,
300
+ }));
301
+ let messages = this.buildMessages(step.context);
302
+ const systemPrompt = this.buildSystemPrompt(step.context);
303
+ const resolved = this.resolveModel('act', step);
304
+ const actClient = this.getClientForService(resolved.service_id);
305
+ const toolCallLog = [];
306
+ let iteration = 0;
307
+ while (iteration < this.maxToolIterations) {
308
+ this.checkBudget();
309
+ const response = await this.callLlmWithRetry({
310
+ model: resolved.model,
311
+ system: systemPrompt,
312
+ messages,
313
+ max_tokens: this.resolveMaxOutputTokens(),
314
+ temperature: 0,
315
+ tools,
316
+ }, actClient);
317
+ const hasToolUse = response.content.some(b => b.type === 'tool_use');
318
+ if (!hasToolUse) {
319
+ this.flushToolLog(step.step_id, toolCallLog);
320
+ return this.parseStepOutput(response, step.context.output_schema);
321
+ }
322
+ const assistantContent = response.content;
323
+ messages = [...messages, { role: 'assistant', content: assistantContent }];
324
+ const toolResults = [];
325
+ for (const block of assistantContent) {
326
+ if (block.type === 'tool_use') {
327
+ // act 步骤拦截 requires_commit=true 的工具;commit 步骤是授权执行点,放行 // @a: anc-exec-requires-commit
328
+ const toolDef = this.toolProvider.list().find(t => t.name === block.name);
329
+ if (!allowCommit && toolDef?.requires_commit) {
330
+ throw new Error('COMMIT_REQUIRED: tool "' + block.name + '" requires commit step');
331
+ }
332
+ const result = await this.toolProvider.execute(block.name, block.input);
333
+ // tool 审计性字段,步骤完成时经 recordStepMeta 写入 // @a: anc-obs-audit
334
+ toolCallLog.push({ name: block.name, result: result.success ? 'success' : 'failure', at: new Date().toISOString() });
335
+ toolResults.push({
336
+ type: 'tool_result',
337
+ tool_use_id: block.id,
338
+ content: result.success ? String(result.result) : `Error: ${result.result}`,
339
+ is_error: !result.success,
340
+ });
341
+ }
342
+ }
343
+ messages = [...messages, { role: 'user', content: toolResults }];
344
+ iteration++;
345
+ }
346
+ throw new Error('MAX_TOOL_ITERATIONS: exceeded tool use iteration limit');
347
+ }
348
+ executeConfirm(step) {
349
+ // confirm 暂停:构造 ExecutionPaused 信号,步骤保持 running 等待 caller 决策
350
+ const node = this.findStepInSpec(step.step_id);
351
+ const options = node?.response_options
352
+ ?? [
353
+ { value: 'approve', label: '批准' },
354
+ { value: 'reject', label: '拒绝' },
355
+ ];
356
+ this.pendingPause = {
357
+ status: 'paused',
358
+ instance_id: this.engine.getInstanceId(),
359
+ step_id: step.step_id,
360
+ pause_reason: node?.require_human ? 'waiting_human' : 'confirm',
361
+ presented_data: {
362
+ summary: step.summary,
363
+ instruction: step.context.instruction,
364
+ context: step.context.inputs,
365
+ },
366
+ response_options: options,
367
+ work_zone: '', // 独立模式不暴露 work_zone 给外部 driver, 见 anc-exec-work-zone
368
+ };
369
+ return 'paused';
370
+ }
371
+ // commit 直接执行不可逆操作——授权已在前序完成(环境预授权或前置 confirm),commit 本身不暂停鉴权
372
+ // 如同 git commit:能执行到此即已授权,直接执行写入。audit 记录执行内幕。 // @a: anc-step-commit
373
+ async executeCommit(step) {
374
+ const node = this.findStepInSpec(step.step_id);
375
+ const target = node?.irreversible_action ?? step.summary;
376
+ const hopLog = this.engine.getHopLog();
377
+ // commit 走 body 执行器(allowCommit=true 放行 requires_commit 工具);无 body 回退 LLM 循环
378
+ const result = await this.executeActBody(step, true);
379
+ // commit_audit 审计性字段:记录不可逆操作执行 // @a: anc-obs-audit
380
+ // authorized_by='policy':授权由前序流程(confirm 或环境策略)建立,非 commit 自身索取
381
+ if (hopLog) {
382
+ hopLog.recordStepMeta(step.step_id, {
383
+ commit_audit: { target, authorized_by: 'policy', result: 'success', at: new Date().toISOString() },
384
+ });
385
+ }
386
+ return result;
387
+ }
388
+ // act/commit 执行:有 hop_python body 走 BodyInterpreter(确定性、无 LLM);
389
+ // 无 body 回退 executeActWithTools(LLM 循环,存量 spec 零回归)。 // @a: anc-exec-act-body-interp
390
+ async executeActBody(step, allowCommit) {
391
+ const node = this.findStepInSpec(step.step_id);
392
+ if (!node?.body) {
393
+ return this.executeActWithTools(step, allowCommit); // fallback
394
+ }
395
+ const toolCallLog = [];
396
+ const interp = new BodyInterpreter({
397
+ inputs: step.context.inputs,
398
+ toolProvider: this.toolProvider,
399
+ allowCommit,
400
+ toolCallLog,
401
+ });
402
+ const outputs = await interp.run(node.body, step.context.output_schema);
403
+ // tool 审计字段:步骤完成时写入 HopLog(与 executeActWithTools 一致)// @a: anc-obs-audit
404
+ this.flushToolLog(step.step_id, toolCallLog);
405
+ return outputs;
406
+ }
407
+ buildApiRequest(context, stepType, step) {
408
+ const system = this.buildSystemPrompt(context);
409
+ const messages = this.buildMessages(context);
410
+ const resolved = this.resolveModel(stepType, step);
411
+ const client = this.getClientForService(resolved.service_id);
412
+ const maxOutput = this.resolveMaxOutputTokens();
413
+ return {
414
+ request: {
415
+ model: resolved.model,
416
+ system,
417
+ messages,
418
+ max_tokens: maxOutput,
419
+ temperature: this.selectTemperature(stepType),
420
+ },
421
+ client,
422
+ };
423
+ }
424
+ buildSystemPrompt(context) {
425
+ const parts = [`Working directory: ${this.hostConfig.workspace_dir}\n\n${context.task_context}`];
426
+ if (context.knowledge_context)
427
+ parts.push(context.knowledge_context);
428
+ if (context.doc_ref_context)
429
+ parts.push(context.doc_ref_context); // L2d doc-ref // @a: anc-exec-doc-ref-injection
430
+ return parts.join('\n\n---\n\n');
431
+ }
432
+ buildMessages(context) {
433
+ const messages = [];
434
+ let progress = context.progress_summary;
435
+ if (context.iteration_history)
436
+ progress += '\n\n' + context.iteration_history;
437
+ if (progress.trim()) {
438
+ messages.push({ role: 'user', content: '[执行进度]\n' + progress });
439
+ }
440
+ const inputsText = this.formatInputs(context.inputs);
441
+ messages.push({ role: 'user', content: '[步骤输入]\n' + inputsText });
442
+ messages.push({ role: 'user', content: '[当前任务]\n' + context.instruction });
443
+ const schemaText = this.formatOutputSchema(context.output_schema);
444
+ messages.push({ role: 'user', content: '[输出要求]\n' + schemaText + '\n\n请严格按上述结构输出 YAML。' });
445
+ return messages;
446
+ }
447
+ formatInputs(inputs) {
448
+ const lines = [];
449
+ for (const [key, value] of Object.entries(inputs)) {
450
+ if (value === null || value === undefined) {
451
+ lines.push(`${key}: None`);
452
+ }
453
+ else if (typeof value === 'string') {
454
+ const display = value.length > 500 ? value.slice(0, 497) + '...' : value;
455
+ lines.push(`${key}: ${display}`);
456
+ }
457
+ else {
458
+ lines.push(`${key}: ${JSON.stringify(value)}`);
459
+ }
460
+ }
461
+ return lines.length > 0 ? lines.join('\n') : '(none)';
462
+ }
463
+ formatOutputSchema(schema) {
464
+ return schema.map(o => {
465
+ let line = `- ${o.name}: ${o.type}`;
466
+ if (o.description)
467
+ line += ` # ${o.description}`;
468
+ return line;
469
+ }).join('\n');
470
+ }
471
+ selectTemperature(stepType) {
472
+ switch (stepType) {
473
+ case 'reason': return 0.3;
474
+ case 'act': return 0;
475
+ case 'check': return 0;
476
+ case 'commit': return 0;
477
+ default: return 0.3;
478
+ }
479
+ }
480
+ async callLlmWithRetry(request, client) {
481
+ const maxRetries = 4;
482
+ let lastError;
483
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
484
+ try {
485
+ const resp = await (client ?? this.defaultClient).messages.create(request);
486
+ this.cumulativeTokens += resp.usage.input_tokens + resp.usage.output_tokens;
487
+ this.checkBudget();
488
+ return resp;
489
+ }
490
+ catch (err) {
491
+ lastError = err;
492
+ if (this.isAuthFailure(err))
493
+ throw err;
494
+ if (this.isContextOverflow(err))
495
+ throw err;
496
+ const retriesForError = this.getMaxRetries(err);
497
+ if (attempt >= retriesForError)
498
+ throw err;
499
+ const delay = 1000 * Math.pow(2, attempt) + Math.random() * 100;
500
+ await this.sleep(delay);
501
+ }
502
+ }
503
+ throw lastError;
504
+ }
505
+ getMaxRetries(err) {
506
+ if (this.isRateLimited(err))
507
+ return 4;
508
+ if (this.isServerError(err))
509
+ return 3;
510
+ if (this.isTimeout(err) || this.isNetworkError(err))
511
+ return 2;
512
+ return 0;
513
+ }
514
+ isRateLimited(err) {
515
+ return err instanceof Anthropic.RateLimitError;
516
+ }
517
+ isServerError(err) {
518
+ return err instanceof Anthropic.InternalServerError;
519
+ }
520
+ isTimeout(err) {
521
+ return err instanceof Anthropic.APIConnectionTimeoutError;
522
+ }
523
+ isNetworkError(err) {
524
+ return err instanceof Anthropic.APIConnectionError;
525
+ }
526
+ isAuthFailure(err) {
527
+ return err instanceof Anthropic.AuthenticationError;
528
+ }
529
+ isContextOverflow(err) {
530
+ if (err instanceof Anthropic.BadRequestError) {
531
+ // 收紧到 Anthropic 上下文超长的实际文案——旧判据裸命中 token/context 子串,
532
+ // 会把无关的鉴权类 'invalid token' BadRequest 误判为溢出、触发 reassembleAggressive。
533
+ const msg = (err.message ?? '').toLowerCase();
534
+ return msg.includes('prompt is too long')
535
+ || msg.includes('context_length')
536
+ || msg.includes('context length exceeded')
537
+ || msg.includes('maximum context')
538
+ || msg.includes('too many tokens')
539
+ || (msg.includes('too long') && msg.includes('token'));
540
+ }
541
+ return false;
542
+ }
543
+ checkBudget() {
544
+ if (this.tokenBudget && this.cumulativeTokens >= this.tokenBudget) {
545
+ throw new Error('BUDGET_EXCEEDED: token budget exhausted');
546
+ }
547
+ }
548
+ checkNoneInputs(step) {
549
+ const inputs = step.context.inputs;
550
+ return Object.values(inputs).some(v => v === null);
551
+ }
552
+ parseStepOutput(response, schema) {
553
+ const text = this.extractTextContent(response);
554
+ const outputs = {};
555
+ if (schema.length === 1) {
556
+ outputs[schema[0].name] = text.trim();
557
+ return outputs;
558
+ }
559
+ // Try YAML-like parsing: "key: value" per line
560
+ for (const decl of schema) {
561
+ const regex = new RegExp(`^${decl.name}:\\s*(.+)$`, 'm');
562
+ const match = text.match(regex);
563
+ if (match) {
564
+ outputs[decl.name] = match[1].trim();
565
+ }
566
+ else {
567
+ outputs[decl.name] = null;
568
+ }
569
+ }
570
+ return outputs;
571
+ }
572
+ extractTextContent(response) {
573
+ return response.content
574
+ .filter(b => b.type === 'text')
575
+ .map(b => b.text)
576
+ .join('\n');
577
+ }
578
+ findStepNode(steps, stepId) {
579
+ for (const s of steps) {
580
+ if (s.step_id === stepId)
581
+ return s;
582
+ if (s.children) {
583
+ const found = this.findStepNode(s.children, stepId);
584
+ if (found)
585
+ return found;
586
+ }
587
+ }
588
+ return null;
589
+ }
590
+ resolveCredential() {
591
+ const baseURL = process.env['ANTHROPIC_BASE_URL'] || this.hostConfig.base_url || undefined;
592
+ const authToken = process.env['ANTHROPIC_AUTH_TOKEN'];
593
+ if (authToken && authToken.trim()) {
594
+ return { apiKey: authToken, baseURL };
595
+ }
596
+ const envKey = process.env['ANTHROPIC_API_KEY']
597
+ ?? process.env['HOPSTEP_ANTHROPIC_API_KEY'];
598
+ if (envKey && envKey.trim())
599
+ return { apiKey: envKey, baseURL };
600
+ if (this.hostConfig.api_key && this.hostConfig.api_key.trim()) {
601
+ return { apiKey: this.hostConfig.api_key, baseURL };
602
+ }
603
+ const configPath = join(homedir(), '.hopstep', 'config.json');
604
+ if (existsSync(configPath)) {
605
+ const stat = statSync(configPath);
606
+ const mode = stat.mode & 0o777;
607
+ if (mode > 0o600) {
608
+ throw new Error(`~/.hopstep/config.json has insecure permissions (${mode.toString(8)}). Must be 0600 or less.`);
609
+ }
610
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
611
+ if (config.api_key && config.api_key.trim())
612
+ return { apiKey: config.api_key, baseURL };
613
+ }
614
+ throw new Error('No API key found. Set ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, or provide via HostConfig.');
615
+ }
616
+ resolveModel(stepType, step) {
617
+ if (step) {
618
+ const stepNode = this.findStepInSpec(step.step_id);
619
+ if (stepNode?.model_override)
620
+ return this.parseModelRef(stepNode.model_override);
621
+ }
622
+ const specConfig = this.engine.getSpec()?.header?.config;
623
+ if (specConfig?.model)
624
+ return this.parseModelRef(specConfig.model);
625
+ const me = this.hostConfig.model_engine;
626
+ if (me?.routing_rules) {
627
+ for (const rule of me.routing_rules) {
628
+ if (rule.match.step_type === stepType)
629
+ return { service_id: rule.service_id, model: rule.model };
630
+ }
631
+ }
632
+ const envModel = process.env['ANTHROPIC_MODEL'];
633
+ if (envModel)
634
+ return { service_id: 'default', model: envModel };
635
+ if (me?.default_model)
636
+ return { service_id: me.default_service_id ?? 'default', model: me.default_model };
637
+ if (this.hostConfig.model)
638
+ return { service_id: 'default', model: this.hostConfig.model };
639
+ return { service_id: 'default', model: 'claude-sonnet-4-20250514' };
640
+ }
641
+ parseModelRef(ref) {
642
+ const slash = ref.indexOf('/');
643
+ if (slash > 0)
644
+ return { service_id: ref.slice(0, slash), model: ref.slice(slash + 1) };
645
+ return { service_id: 'default', model: ref };
646
+ }
647
+ findStepInSpec(stepId) {
648
+ const steps = this.engine.getSpec()?.steps;
649
+ if (!steps)
650
+ return null;
651
+ return this.findStepNode(steps, stepId);
652
+ }
653
+ /** 解析 API 输出 token 上限:env → resource_limits → 缺省。单点定义(原两处逐字复制)。
654
+ * 通用 env `HOPJIT_MAX_OUTPUT_TOKENS` 优先;`CLAUDE_CODE_MAX_OUTPUT_TOKENS` 保留作兼容(CC 宿主已设的不破坏)。 */
655
+ resolveMaxOutputTokens() {
656
+ return Number(process.env['HOPJIT_MAX_OUTPUT_TOKENS']
657
+ ?? process.env['CLAUDE_CODE_MAX_OUTPUT_TOKENS']
658
+ ?? this.hostConfig.resource_limits?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS);
659
+ }
660
+ /** replan 失败收尾:attempts+1 写回,达上限则 failStep。error 分支与 catch 分支共用。 */
661
+ recordReplanFailure(subtaskId, attempts, maxReplanAttempts, reasonText) {
662
+ this.replanAttempts.set(subtaskId, attempts + 1);
663
+ if (attempts + 1 >= maxReplanAttempts) {
664
+ this.engine.failStep(subtaskId, reasonText);
665
+ }
666
+ }
667
+ /** 步骤完成时把工具调用日志写入 HopLog(非空才写)。executeActWithTools / executeActBody 共用。 */
668
+ flushToolLog(stepId, toolCallLog) {
669
+ const hopLog = this.engine.getHopLog();
670
+ if (hopLog && toolCallLog.length > 0) {
671
+ hopLog.recordStepMeta(stepId, { tool: toolCallLog });
672
+ }
673
+ }
674
+ getClientForService(service_id) {
675
+ if (this.clients.has(service_id))
676
+ return this.clients.get(service_id);
677
+ const envKey = process.env[`${service_id.toUpperCase()}_API_KEY`];
678
+ const envUrl = process.env[`${service_id.toUpperCase()}_BASE_URL`];
679
+ if (envKey) {
680
+ const client = envUrl ? new Anthropic({ apiKey: envKey, baseURL: envUrl }) : new Anthropic({ apiKey: envKey });
681
+ this.clients.set(service_id, client);
682
+ return client;
683
+ }
684
+ return this.defaultClient;
685
+ }
686
+ hasLackOfInfo(result) {
687
+ return result.lack_of_info !== undefined && result.lack_of_info !== null;
688
+ }
689
+ sleep(ms) {
690
+ return new Promise(resolve => setTimeout(resolve, ms));
691
+ }
692
+ }