@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 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.
@@ -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;
@@ -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
- if (turns >= maxTurns || toolCalls.length === 0)
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
- return fail({ code: 'ACTION_TOOL_NOT_ALLOWED', message: 'Model requested a tool outside the ReAct allow-list.', retryable: false });
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
- if (turns > maxTurns)
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;
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
+ "license": "PolyForm-Noncommercial-1.0.0",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "https://github.com/zhuhengtan/Pulse"