@hunterzhu/pulse-runtime 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/dsl/program.d.ts +2 -0
- package/dist/dsl/program.js +158 -5
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @hunterzhu/pulse-runtime
|
|
2
2
|
|
|
3
|
+
[简体中文](https://github.com/zhuhengtan/Pulse/blob/main/packages/runtime/README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
The core agent runtime for Pulse: programs, lanes, agents, and outcomes.
|
|
4
6
|
|
|
5
7
|
## Requirements
|
|
@@ -40,3 +42,7 @@ tools is described in the docs.
|
|
|
40
42
|
|
|
41
43
|
- [Documentation](https://github.com/zhuhengtan/Pulse#readme)
|
|
42
44
|
- [Source](https://github.com/zhuhengtan/Pulse/tree/main/packages/runtime)
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
This package is licensed under [PolyForm Noncommercial 1.0.0](./LICENSE). Commercial use requires separate permission. This is a source-available noncommercial license, not an OSI-approved open-source license.
|
package/dist/dsl/program.d.ts
CHANGED
|
@@ -155,6 +155,8 @@ interface AffinityAdviceGroup {
|
|
|
155
155
|
keys: string[];
|
|
156
156
|
signals: string[];
|
|
157
157
|
}
|
|
158
|
+
/** Models often wrap the real arguments, put a path in title, or pack a shell command line into one string. */
|
|
159
|
+
export declare function normalizeModelToolInput(toolName: string, input: unknown): JsonValue;
|
|
158
160
|
export declare class StepBuilder<TState = JsonValue> {
|
|
159
161
|
readonly config: {
|
|
160
162
|
id: string;
|
package/dist/dsl/program.js
CHANGED
|
@@ -199,6 +199,107 @@ function collectResumeResultRefs(input, refs) {
|
|
|
199
199
|
refs.add(ref);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
+
/** Models often wrap the real arguments, put a path in title, or pack a shell command line into one string. */
|
|
203
|
+
export function normalizeModelToolInput(toolName, input) {
|
|
204
|
+
const unwrapped = aliasTitleToPath(toolName, unwrapToolEnvelope(input));
|
|
205
|
+
if (toolName !== 'shell.exec' || !unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped))
|
|
206
|
+
return unwrapped;
|
|
207
|
+
return normalizeShellInput(unwrapped);
|
|
208
|
+
}
|
|
209
|
+
function aliasTitleToPath(toolName, input) {
|
|
210
|
+
if (!toolName.startsWith('fs.') || !input || typeof input !== 'object' || Array.isArray(input))
|
|
211
|
+
return input;
|
|
212
|
+
const record = input;
|
|
213
|
+
if (typeof record.path === 'string' && record.path.length > 0)
|
|
214
|
+
return input;
|
|
215
|
+
if (typeof record.title !== 'string' || record.title.trim().length === 0)
|
|
216
|
+
return input;
|
|
217
|
+
const path = record.title.trim();
|
|
218
|
+
const copy = { ...record, path };
|
|
219
|
+
delete copy.title;
|
|
220
|
+
return copy;
|
|
221
|
+
}
|
|
222
|
+
function normalizeShellInput(record) {
|
|
223
|
+
const extras = { ...(typeof record.cwd === 'string' ? { cwd: record.cwd } : {}), ...(typeof record.timeoutMs === 'number' ? { timeoutMs: record.timeoutMs } : {}) };
|
|
224
|
+
const declaredArgs = Array.isArray(record.args) ? record.args.filter((item) => typeof item === 'string') : [];
|
|
225
|
+
if (Array.isArray(record.command) && record.command.length > 0 && record.command.every((item) => typeof item === 'string')) {
|
|
226
|
+
const command = record.command[0];
|
|
227
|
+
if (typeof command !== 'string' || command.length === 0)
|
|
228
|
+
return record;
|
|
229
|
+
const rest = record.command.slice(1).filter((item) => typeof item === 'string');
|
|
230
|
+
const args = [...rest, ...declaredArgs];
|
|
231
|
+
return { command, ...(args.length ? { args } : {}), ...extras };
|
|
232
|
+
}
|
|
233
|
+
if (typeof record.command === 'string') {
|
|
234
|
+
const split = splitPlainCommand(record.command);
|
|
235
|
+
if (!split)
|
|
236
|
+
return record;
|
|
237
|
+
const args = [...split.args, ...declaredArgs];
|
|
238
|
+
return { command: split.command, ...(args.length ? { args } : {}), ...extras };
|
|
239
|
+
}
|
|
240
|
+
const executable = typeof record.executable === 'string' ? record.executable : undefined;
|
|
241
|
+
const argv = Array.isArray(record.argv) ? record.argv : Array.isArray(record.args) ? record.args : undefined;
|
|
242
|
+
if (executable && argv && argv.every((item) => typeof item === 'string'))
|
|
243
|
+
return { command: executable, args: argv, ...extras };
|
|
244
|
+
return record;
|
|
245
|
+
}
|
|
246
|
+
/** A command line with no unquoted shell syntax is an executable plus arguments. */
|
|
247
|
+
function splitPlainCommand(command) {
|
|
248
|
+
const parts = tokenizeCommand(command);
|
|
249
|
+
const executable = parts?.[0];
|
|
250
|
+
if (!parts || !executable || parts.length < 2)
|
|
251
|
+
return undefined;
|
|
252
|
+
return { command: executable, args: parts.slice(1) };
|
|
253
|
+
}
|
|
254
|
+
function tokenizeCommand(command) {
|
|
255
|
+
const parts = [];
|
|
256
|
+
let current = '';
|
|
257
|
+
let quote;
|
|
258
|
+
for (let index = 0; index < command.length; index++) {
|
|
259
|
+
const char = command[index];
|
|
260
|
+
if (quote) {
|
|
261
|
+
if (char === quote)
|
|
262
|
+
quote = undefined;
|
|
263
|
+
else if (char === '\\' && quote === '"' && index + 1 < command.length)
|
|
264
|
+
current += command[++index];
|
|
265
|
+
else
|
|
266
|
+
current += char;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (char === '"' || char === "'") {
|
|
270
|
+
quote = char;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (/\s/.test(char)) {
|
|
274
|
+
if (current) {
|
|
275
|
+
parts.push(current);
|
|
276
|
+
current = '';
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (/[|&;<>`\n]/.test(char) || (char === '$' && command[index + 1] === '('))
|
|
281
|
+
return undefined;
|
|
282
|
+
current += char;
|
|
283
|
+
}
|
|
284
|
+
if (quote)
|
|
285
|
+
return undefined;
|
|
286
|
+
if (current)
|
|
287
|
+
parts.push(current);
|
|
288
|
+
return parts.length > 0 ? parts : undefined;
|
|
289
|
+
}
|
|
290
|
+
function unwrapToolEnvelope(input) {
|
|
291
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
292
|
+
return (input ?? {});
|
|
293
|
+
const record = input;
|
|
294
|
+
const keys = Object.keys(record);
|
|
295
|
+
const key = keys[0];
|
|
296
|
+
if (keys.length === 1 && key !== undefined && (key === 'input' || key === 'arguments' || key === 'args' || key === 'parameters')) {
|
|
297
|
+
const inner = record[key];
|
|
298
|
+
if (inner && typeof inner === 'object' && !Array.isArray(inner))
|
|
299
|
+
return inner;
|
|
300
|
+
}
|
|
301
|
+
return input;
|
|
302
|
+
}
|
|
202
303
|
/** Return a wait resolution even when the scheduler wrapped it in a control error. */
|
|
203
304
|
function waitResolution(input) {
|
|
204
305
|
if (!input)
|
|
@@ -431,8 +532,37 @@ export class StepBuilder {
|
|
|
431
532
|
const resultRefsFromWait = (ctx) => { const resolution = waitResolution(ctx.resumeInput); return resolution === undefined ? [] : Object.values(resolution.dependencies).flatMap((dependency) => dependency.state === 'settled' && dependency.outcome.resultRef ? [dependency.outcome.resultRef] : []); };
|
|
432
533
|
const instruction = (ctx) => boundedInstruction(typeof options.instruction === 'string' ? options.instruction : options.instruction({ goal: ctx.goal, state: scalarProjection(ctx.laneState), global: ctx.global }));
|
|
433
534
|
const submitModel = (ctx, turn, inputs = {}) => { const resultRefs = [...new Set(inputs.results ?? [])]; const findingRefs = [...new Set(inputs.findings ?? [])]; const artifactRefs = [...new Set(inputs.artifacts ?? [])]; const dataRefs = [...resultRefs, ...findingRefs, ...artifactRefs.map((ref) => ({ kind: 'artifact', ref }))]; const requirements = { ...(options.requirements ?? {}), ...(options.toolAllow === undefined ? {} : { toolCalling: true }) }; return { actions: [{ type: 'submit_effects', effects: [{ key: `${name}-turn-${turn}`, kind: 'llm', concurrencyClass: 'llm', input: programLLMInput(this.config, { task: options.task ?? 'reason', instruction: instruction(ctx), inputs: { ...(resultRefs.length ? { results: resultRefs } : {}), ...(findingRefs.length ? { findings: findingRefs } : {}), ...(artifactRefs.length ? { artifacts: artifactRefs } : {}), ...(inputs.events?.length ? { events: [...new Set(inputs.events)] } : {}), ...(inputs.conversation?.length ? { conversation: inputs.conversation } : {}) }, turn, ...(inputs.toolDiscovery === undefined ? {} : { toolDiscovery: inputs.toolDiscovery }), ...(options.outputSchema === undefined ? {} : { outputSchema: zodJsonSchema(options.outputSchema) }), ...(Object.keys(requirements).length ? { requirements } : {}) }), ...(dataRefs.length ? { derivedFrom: dataRefs } : {}) }], wait: { onUnsatisfied: 'resume_with_error' } }], next: { programId: this.config.id, programVersion: this.config.version, step: `${name}:decode`, locals: writeTurns(ctx, turn, inputs) }, locals: writeTurns(ctx, turn, inputs) }; };
|
|
535
|
+
const invalidInputRecovery = (ctx) => {
|
|
536
|
+
const input = ctx.resumeInput;
|
|
537
|
+
if (!input || input.type !== 'control_error' || input.error.code !== 'INVALID_TOOL_INPUT')
|
|
538
|
+
return undefined;
|
|
539
|
+
const message = input.error.message.slice(0, 1000);
|
|
540
|
+
const failureKey = `${name}ToolFailure`;
|
|
541
|
+
const signature = contentHash([{ code: 'INVALID_TOOL_INPUT', message }]);
|
|
542
|
+
const prior = sdkLocals(ctx.lane.resume.locals)[failureKey];
|
|
543
|
+
const count = prior?.signature === signature ? (prior.count ?? 0) + 1 : 1;
|
|
544
|
+
if (count >= 3) {
|
|
545
|
+
const error = { code: 'REPEATED_TOOL_FAILURE', message: `The same tool failure repeated three times: ${message}`, retryable: false };
|
|
546
|
+
return { next: options.onError ? options.onError(error, ctx) : { fail: error } };
|
|
547
|
+
}
|
|
548
|
+
const hint = message.includes('tool fs.stage')
|
|
549
|
+
? ' fs.stage accepts path and content for a new file of at most 8192 bytes, or operation begin, inspect, append, or commit. Put the workspace path in path.'
|
|
550
|
+
: message.includes('tool shell.exec')
|
|
551
|
+
? ' shell.exec command is the executable alone; put each flag in args, for example {"command":"node","args":["--test"]}.'
|
|
552
|
+
: '';
|
|
553
|
+
const previous = readInputs(ctx);
|
|
554
|
+
const current = options.inputs?.(ctx) ?? {};
|
|
555
|
+
const notice = `Runtime tool input notice: the previous tool call was not executed. ${message}${hint} Submit a corrected call that matches the tool schema, or choose a different authorized tool. Do not repeat the same arguments.`;
|
|
556
|
+
const inputs = { ...current, ...previous, conversation: [...(current.conversation ?? []), { role: 'user', content: notice }] };
|
|
557
|
+
const output = submitModel(ctx, readTurns(ctx) + 1, inputs);
|
|
558
|
+
const locals = output.locals;
|
|
559
|
+
return { ...output, next: `${name}:decode`, locals: { ...locals, $sdk: { ...sdkLocals(locals), [failureKey]: { signature, count, observations: [{ code: 'INVALID_TOOL_INPUT', message }] }, [`${name}QueuedTools`]: [], [`${name}StopAfterSerial`]: false } } };
|
|
560
|
+
};
|
|
434
561
|
this.handlers.set(name, (ctx) => { const resetToken = options.resetTurnsOnEntry?.(ctx); const priorToken = sdkLocals(ctx.lane.resume.locals)[resetTokenKey]; const reset = resetToken !== undefined && priorToken !== resetToken; const turn = (reset ? 0 : readTurns(ctx)) + 1; const inputs = options.inputs?.(ctx) ?? {}; const output = submitModel(ctx, turn, inputs); const locals = writeTurns(ctx, turn, inputs, resetToken); return { ...output, next: `${name}:decode`, locals }; });
|
|
435
562
|
this.handlers.set(`${name}:tools`, (ctx) => {
|
|
563
|
+
const recovered = invalidInputRecovery(ctx);
|
|
564
|
+
if (recovered)
|
|
565
|
+
return recovered;
|
|
436
566
|
const turn = readTurns(ctx);
|
|
437
567
|
const previous = readInputs(ctx);
|
|
438
568
|
const current = options.inputs?.(ctx) ?? {};
|
|
@@ -495,6 +625,9 @@ export class StepBuilder {
|
|
|
495
625
|
return { ...output, next: `${name}:decode`, locals: { ...locals, $sdk: { ...sdkLocals(locals), [queuedKey]: [], [stopAfterSerialKey]: false, [failureKey]: nextFailure, [progressKey]: { hashes: [...hashes].slice(-128), repeats } } } };
|
|
496
626
|
});
|
|
497
627
|
this.handlers.set(`${name}:decode`, (ctx) => {
|
|
628
|
+
const recovered = invalidInputRecovery(ctx);
|
|
629
|
+
if (recovered)
|
|
630
|
+
return recovered;
|
|
498
631
|
const turns = readTurns(ctx);
|
|
499
632
|
const ref = resultRefFromWait(ctx);
|
|
500
633
|
const value = ref ? readResult(ctx, ref) ?? null : null;
|
|
@@ -538,7 +671,9 @@ export class StepBuilder {
|
|
|
538
671
|
return { actions: [], next: '$compact:summarize', locals: { ...locals, $sdk: { ...sdk, compactPending: true, compactReturnStep: `${name}:decode`, [pendingResultKey]: ref } } };
|
|
539
672
|
}
|
|
540
673
|
if (finishReason === 'tool_calls') {
|
|
541
|
-
|
|
674
|
+
// The last permitted turn may still carry the write that finishes the stage.
|
|
675
|
+
// A further tool request after that batch is the actual turn limit.
|
|
676
|
+
if (toolCalls.length === 0 || turns > maxTurns)
|
|
542
677
|
return maxTurnsReached();
|
|
543
678
|
const canonicalToolName = (name) => {
|
|
544
679
|
if (options.toolAllow === undefined || options.toolAllow.includes(name))
|
|
@@ -547,8 +682,25 @@ export class StepBuilder {
|
|
|
547
682
|
return aliases.length === 1 ? aliases[0] : undefined;
|
|
548
683
|
};
|
|
549
684
|
const invalidTool = toolCalls.find((call) => { const item = call && typeof call === 'object' && !Array.isArray(call) ? call : {}; const toolName = typeof item.name === 'string' ? item.name : ''; const canonical = canonicalToolName(toolName); return !toolName || canonical === undefined || (options.toolAllow !== undefined && !options.toolAllow.includes(canonical)); });
|
|
550
|
-
if (invalidTool !== undefined)
|
|
551
|
-
|
|
685
|
+
if (invalidTool !== undefined) {
|
|
686
|
+
const item = invalidTool && typeof invalidTool === 'object' && !Array.isArray(invalidTool) ? invalidTool : {};
|
|
687
|
+
const requested = typeof item.name === 'string' && item.name.length > 0 ? item.name : '(unnamed)';
|
|
688
|
+
const allowed = (options.toolAllow ?? []).join(', ');
|
|
689
|
+
const message = `Tool ${requested} is not available.${allowed ? ` Available tools: ${allowed}.` : ''}`;
|
|
690
|
+
const failureKey = `${name}ToolFailure`;
|
|
691
|
+
const signature = contentHash([{ code: 'ACTION_TOOL_NOT_ALLOWED', message }]);
|
|
692
|
+
const prior = sdkLocals(ctx.lane.resume.locals)[failureKey];
|
|
693
|
+
const count = prior?.signature === signature ? (prior.count ?? 0) + 1 : 1;
|
|
694
|
+
if (count >= 3)
|
|
695
|
+
return fail({ code: 'ACTION_TOOL_NOT_ALLOWED', message, retryable: false });
|
|
696
|
+
const previous = readInputs(ctx);
|
|
697
|
+
const current = options.inputs?.(ctx) ?? {};
|
|
698
|
+
const notice = `Runtime tool notice: the previous tool call was not executed. ${message} Choose an available tool. Do not invent tool names.`;
|
|
699
|
+
const inputs = { ...current, ...previous, conversation: [...(current.conversation ?? []), { role: 'user', content: notice }] };
|
|
700
|
+
const output = submitModel(ctx, turns + 1, inputs);
|
|
701
|
+
const locals = output.locals;
|
|
702
|
+
return { ...output, next: `${name}:decode`, locals: { ...locals, $sdk: { ...sdkLocals(locals), [failureKey]: { signature, count, observations: [{ code: 'ACTION_TOOL_NOT_ALLOWED', message }] } } } };
|
|
703
|
+
}
|
|
552
704
|
const resolution = waitResolution(ctx.resumeInput);
|
|
553
705
|
const sourceEffectId = ref !== undefined && ctx.results.meta(ref)?.producer.kind === 'effect'
|
|
554
706
|
? ctx.results.meta(ref)?.producer.id
|
|
@@ -560,7 +712,7 @@ export class StepBuilder {
|
|
|
560
712
|
const originalId = typeof item.toolCallId === 'string' ? item.toolCallId : `call-${index + 1}`;
|
|
561
713
|
const rawToolName = typeof item.name === 'string' ? item.name : '';
|
|
562
714
|
const toolName = canonicalToolName(rawToolName);
|
|
563
|
-
return { originalId, toolName, toolCallId: options.scopeToolCallsToEffect ? `${name}:${sourceEffectId ?? 'turn'}:${turns}:${originalId}` : `${name}:${turns}:${originalId}`, input: item.input ?? {} };
|
|
715
|
+
return { originalId, toolName, toolCallId: options.scopeToolCallsToEffect ? `${name}:${sourceEffectId ?? 'turn'}:${turns}:${originalId}` : `${name}:${turns}:${originalId}`, input: normalizeModelToolInput(toolName, item.input ?? {}) };
|
|
564
716
|
});
|
|
565
717
|
const askCalls = calls.filter((call) => String(call.toolName).startsWith('ask.'));
|
|
566
718
|
if (askCalls.length > 0) {
|
|
@@ -685,7 +837,8 @@ export class StepBuilder {
|
|
|
685
837
|
}
|
|
686
838
|
return dispatchTools(makeToolEffects(calls), clearPendingResult(ctx));
|
|
687
839
|
}
|
|
688
|
-
|
|
840
|
+
// The answer after a tool batch started on the last permitted turn is still that turn's result.
|
|
841
|
+
if (turns > maxTurns + 1)
|
|
689
842
|
return maxTurnsReached();
|
|
690
843
|
if (options.outputSchema) {
|
|
691
844
|
const outputValue = record?.structured === undefined ? value : record.structured;
|