@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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/act-body-interpreter.d.ts +24 -0
- package/dist/act-body-interpreter.js +159 -0
- package/dist/act-body-parser.d.ts +10 -0
- package/dist/act-body-parser.js +577 -0
- package/dist/act-builtins.d.ts +12 -0
- package/dist/act-builtins.js +104 -0
- package/dist/ast-helpers.d.ts +26 -0
- package/dist/ast-helpers.js +58 -0
- package/dist/ast-runtime.d.ts +49 -0
- package/dist/ast-runtime.js +224 -0
- package/dist/ast-types.d.ts +260 -0
- package/dist/ast-types.js +4 -0
- package/dist/cli-types.d.ts +190 -0
- package/dist/cli-types.js +4 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +623 -0
- package/dist/dispatcher.d.ts +92 -0
- package/dist/dispatcher.js +692 -0
- package/dist/doc-ref.d.ts +41 -0
- package/dist/doc-ref.js +214 -0
- package/dist/engine-traverse.d.ts +37 -0
- package/dist/engine-traverse.js +385 -0
- package/dist/engine.d.ts +141 -0
- package/dist/engine.js +1792 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +34 -0
- package/dist/hoplog.d.ts +120 -0
- package/dist/hoplog.js +501 -0
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +802 -0
- package/dist/persistence.d.ts +60 -0
- package/dist/persistence.js +208 -0
- package/dist/prompt.d.ts +130 -0
- package/dist/prompt.js +1014 -0
- package/dist/provider-types.d.ts +134 -0
- package/dist/provider-types.js +3 -0
- package/dist/runtime-types.d.ts +84 -0
- package/dist/runtime-types.js +4 -0
- package/dist/tools.d.ts +16 -0
- package/dist/tools.js +141 -0
- package/dist/validator.d.ts +23 -0
- package/dist/validator.js +959 -0
- package/driver/codex/AGENTS.md +54 -0
- package/driver/hopskill-build/SKILL.md +137 -0
- package/driver/hopskill-build/references/spec-skeleton.md +132 -0
- package/driver/hopskill-build/references/step-type-cheatsheet.md +56 -0
- package/driver/hopskill-build/references/three-focus-rules.md +148 -0
- package/driver/hopspec-skill.md +187 -0
- package/driver/references/cli-discovery.md +36 -0
- package/driver/references/discovery.md +45 -0
- package/driver/references/driver-subagent.md +50 -0
- package/driver/references/parallel-worker.md +50 -0
- package/driver/references/step-execution-rules.md +47 -0
- 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
|
+
}
|