工具、输出与记录
Tool 使用本地离线模型选择已登记工具并消费真实反馈。输出订阅交付业务内容,记录安装保存调用事实;调用退出探针核对关闭故障与占用释放。
先执行 pnpm build,再执行下列命令。所有示例均在本机 Node.js 中运行。
让模型选择工具并依据真实反馈回答
跟随两轮离线请求:第一轮选择翻译节点,第二轮读取工具反馈形成回答。运行后得到「你好」,断言核对请求恰好两次,且第二次请求包含节点实际产生的反馈。
sh
node examples/tool.mjsjs
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { z } from 'zod';
import { Resource } from '@weiliang0121/capix-resources';
import { Scene, Node } from '@weiliang0121/capix-structure';
import { Runner, Resources } from '@weiliang0121/capix-runtime';
import { Interactions } from '@weiliang0121/capix-interaction';
import { requireModelRequest, requireModelReply } from '@weiliang0121/capix-model-provider';
import { ModelExchanges, ModelExchangeResource } from '@weiliang0121/capix-model-exchanges';
import { Tool, ToolModelResource } from '@weiliang0121/capix-tool';
// Node 仍是普通处理结构;Tool 从同一份登记生成目录并组织成员调用。
export function createToolRunner(provider) {
const translate = new Node({ run: (work) => ({ hello: '你好' })[work.input.word] ?? work.input.word });
const tool = new Tool({
policyVersion: 'dictionary.v1',
tools: {
translate: {
node: translate,
description: 'Translate a word into Chinese.',
input: z.strictObject({ word: z.string() }),
result: { succeeded: (text) => ({ status: 'ok', content: [{ kind: 'text', text }] }) },
},
},
});
// 宿主登记独立交互;取消一次调用的等待,不会取消这个发送者。
const contract = { key: 'example.tool.model.v1', request: requireModelRequest, reply: requireModelReply };
const interactions = new Interactions().register(contract, { send: (request) => provider.complete(request) });
const exchanges = new ModelExchanges();
const resources = new Resources().register(new Resource(exchanges), ModelExchangeResource).register(new Resource({ exchanges, channel: interactions.bind(contract), ...(provider.inspectRequest ? { inspection: provider } : {}) }), ToolModelResource);
return new Runner(new Scene(tool), resources, interactions).build();
}
// 离线协议示例:第一次请求选择工具,第二次使用真实工具反馈回答。
// 换成配置好的 ChatCompletionsProvider 后,Tool、节点和资源登记不变。
const requests = [];
const offlineModel = {
async complete(request) {
requests.push(request);
const feedback = request.exchanges?.at(-1)?.feedback[0];
return {
formatVersion: 1,
id: feedback ? 'answer' : 'choose-tool',
model: 'offline-example',
output: feedback ? feedback.content : [{ kind: 'tool-call', id: 'lookup', name: 'translate', arguments: { encoding: 'json-text', text: '{"word":"hello"}' } }],
termination: { kind: 'completed', reason: feedback ? 'stop' : 'tool_calls' },
usage: null,
native: { format: 'chat-completions', version: 1 },
};
},
};
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const runner = createToolRunner(offlineModel);
const result = await runner.run({ request: { messages: [{ role: 'user', content: 'Translate hello.' }] } });
assert.equal(result, '你好');
assert.equal(requests.length, 2);
assert.equal(requests[0].exchanges?.length ?? 0, 0);
assert.deepEqual(requests[1].exchanges.at(-1).feedback[0].content, [{ kind: 'text', text: '你好' }]);
assert.equal(runner.isRunning, false);
console.log(result);
}跨多次运行消费输出,再解除订阅
同一个输出订阅接收前两次调用的逐字增量,解除后第三次调用仍能返回最终值。运行后核对事件中的调用身份各不相同、解除订阅后不再新增事件,以及空闲取消返回 false。
sh
node examples/runner-output.mjsjs
import assert from 'node:assert/strict';
import { Scene } from '@weiliang0121/capix-structure';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
function text(value) {
if (typeof value !== 'string') throw new TypeError('Expected text');
return value;
}
const scene = new Scene({
output: { result: text, delta: text },
run(work) {
for (const character of work.input) work.output.emit(character);
return work.input;
},
});
const runner = new Runner(scene, new Resources()).build();
// 订阅属于 Runner,每条事件保留本次根调用的 contextId。
const events = [];
const unsubscribe = runner.output.subscribe({
receive(event) {
events.push(event);
if (event.kind === 'delta') process.stdout.write(event.delta);
},
});
assert.equal(await runner.run('第一轮输出\n'), '第一轮输出\n');
assert.equal(await runner.run('第二轮仍使用同一个订阅\n'), '第二轮仍使用同一个订阅\n');
unsubscribe();
// 停止消费不影响执行;最终结果仍从本次 Promise 取得。
const eventCount = events.length;
const final = await runner.run('解除订阅后的最终结果');
assert.equal(final, '解除订阅后的最终结果');
assert.equal(events.length, eventCount);
assert.equal(
events
.filter((event) => event.kind === 'delta')
.map((event) => event.delta)
.join(''),
'第一轮输出\n第二轮仍使用同一个订阅\n',
);
assert.deepEqual(
events.filter((event) => event.kind === 'completed').map((event) => event.result),
['第一轮输出\n', '第二轮仍使用同一个订阅\n'],
);
assert.equal(new Set(events.map((event) => event.entryId)).size, 2);
assert.equal(new Set(events.map((event) => event.contextId)).size, 2);
assert.equal(runner.isRunning, false);
console.log(final);
// 宿主需要停止当前运行时调用 runner.cancel();空闲时不会影响下一轮。
const cancelled = runner.cancel();
assert.equal(cancelled, false);
console.log('空闲取消:', cancelled);同时写业务记录与记录真实调用
节点通过资源写一条自定义记录,可选的 bindRecording 安装另行记录实际调用。运行后核对自定义正文与输入一致,并看到 Scene 和 Node 两条成功调用记录;本例没有配置磁盘持久化。
sh
node examples/records.mjsjs
import assert from 'node:assert/strict';
import { RecordBook, Recorder, RecordResource } from '@weiliang0121/capix-records';
import { Resource } from '@weiliang0121/capix-resources';
import { Node, Scene } from '@weiliang0121/capix-structure';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
import { bindRecording } from '@weiliang0121/capix-runtime/recording';
// Records live independently; resources grant specific operations on this entity.
const recorder = new Recorder(new RecordBook());
const resources = new Resources().register(new Resource(recorder), RecordResource.write);
const note = new Node({
deps: { records: RecordResource.write.request() },
run: (work) => work.resources.records.create({ message: work.input }),
});
// This optional preset records actual calls in the same entity through its own windows.
const runner = new Runner(new Scene(note), resources, undefined, { observations: [bindRecording(recorder)] }).build();
const result = await runner.run('记录是一种可通过资源访问的实体');
console.log(result.kind, result.data.value);
console.log('已结束调用:', [...recorder.book.values()].filter((record) => record.kind === 'invocation' && record.status === 'succeeded').length);
console.log('记录自身写入的资源事实:', [...recorder.book.values()].filter((record) => record.kind === 'resource' && record.access === 'records.construct').length);
assert.equal(result.kind, 'custom');
assert.deepEqual(result.data.value, { message: '记录是一种可通过资源访问的实体' });
assert.equal([...recorder.book.values()].filter((record) => record.kind === 'invocation' && record.status === 'succeeded').length, 2);
assert.equal([...recorder.book.values()].filter((record) => record.kind === 'custom').length, 1);
assert.equal(runner.isRunning, false);核对关闭故障与占用释放
这是一组诊断探针:主动制造正文与清理错误,检查两者都被保留,并核对 Runner 占用及本地 CP 占用释放。运行后 JSON 中的 checks 应全部为 true;低层身份复用只用于检查占用,不是业务恢复写法,也没有保存可恢复的 point。
sh
node examples/invocation-exit.mjsjs
// Diagnostic probes, not a recommended application pattern. No real model or disk store.
// `node examples/invocation-exit.mjs` exits nonzero while an exit guarantee is unmet.
import { Resource, ResourceOffer, ResourceSupply } from '@weiliang0121/capix-resources';
import { CallableContext, Invocation, InvocationResources, invocationResourceFailures } from '@weiliang0121/capix-invocation';
import { MemoryCheckpointStore } from '@weiliang0121/capix-checkpoint';
import { Scene, Resources, Runner } from '@weiliang0121/capix-runtime';
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function contains(error, expected, seen = new Set()) {
if (error === expected) return true;
if (!(error instanceof Error) || seen.has(error)) return false;
seen.add(error);
return contains(error.cause, expected, seen) || (error instanceof AggregateError && error.errors.some((item) => contains(item, expected, seen)));
}
function pendingResource(cleanupFailure) {
const response = deferred();
const offer = new ResourceOffer({
methods: {
read(_origin, _input, operation) {
operation.onClose(() => {
throw cleanupFailure;
});
return response.promise;
},
},
});
let pending;
return {
offer,
start(endpoint) {
pending = endpoint.read();
void pending.catch(() => undefined); // Probe owns this intentionally unawaited operation.
},
async finish() {
response.resolve('late resource result');
await pending?.catch(() => undefined);
},
};
}
async function failurePreservation() {
const bodyFailure = new Error('business body failed');
const cleanupFailure = new Error('resource cleanup failed');
const pending = pendingResource(cleanupFailure);
const scene = new Scene({
deps: { data: pending.offer.request() },
run(work) {
pending.start(work.resources.data);
throw bodyFailure;
},
});
const runner = new Runner(scene, new Resources().register(new Resource(null), pending.offer)).build();
let delivered;
try {
await runner.run(null);
} catch (error) {
delivered = error;
} finally {
await pending.finish();
}
return {
probe: 'body failure plus cleanup failure',
checks: { bodyFailurePreserved: contains(delivered, bodyFailure), cleanupFailurePreserved: contains(delivered, cleanupFailure), runnerReleased: !runner.isRunning },
observed: { delivered: delivered?.message, cause: delivered?.cause?.message },
};
}
async function checkpointRelease() {
const cleanupFailure = new Error('resource cleanup failed');
const pending = pendingResource(cleanupFailure);
const deps = { data: pending.offer.request() };
const supply = ResourceSupply.capture([pending.offer.provide(() => null)], undefined);
const resources = new InvocationResources(deps, supply.entries);
const context = new CallableContext(null, 'node', {}, resources);
const store = new MemoryCheckpointStore();
const call = new Invocation({
context,
signal: context.signal,
checkpoints: { store },
work: {
execute(current) {
pending.start(current.resources.values.data);
return 'body result';
},
},
});
resources.attach({ contextId: context.id, signal: context.signal, failures: invocationResourceFailures, validate: () => call.requireActive(), error: (error, allow) => call.reject(error, allow) });
let delivered;
let nextResult;
let nextError;
try {
try {
await call.run();
} catch (error) {
delivered = error;
}
// Diagnostic only: reuse the Context ID to query local exclusion, not to resume business.
// No CP point was written, so this probe does not claim a recoverable execution exists.
const nextContext = new CallableContext(null, 'node');
nextContext.installIdentity(context.id);
const next = new Invocation({ context: nextContext, signal: nextContext.signal, checkpoints: { store }, work: { execute: () => 'second result' } });
try {
nextResult = await next.run();
} catch (error) {
nextError = error;
}
} finally {
await pending.finish();
}
return {
probe: 'low-level cleanup and checkpoint claim release',
checks: { cleanupFailurePreserved: contains(delivered, cleanupFailure), checkpointClaimReleased: nextResult === 'second result' },
observed: { delivered: delivered?.message, contextStatus: context.status, invocationFailureRecorded: !!call.failure, nextError: nextError?.message },
};
}
const probes = [await failurePreservation(), await checkpointRelease()];
console.log(JSON.stringify(probes, null, 2));
if (probes.some((probe) => Object.values(probe.checks).some((passed) => !passed))) process.exitCode = 1;