@linnlabs/linnkit 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -16,6 +16,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ---
18
18
 
19
+ ## [0.9.0] - 2026-05-22
20
+
21
+ ### Added
22
+
23
+ - `appendStreamingProviderReasoningDetails` / `compactProviderReasoningDetails` / `compactReasoningDetailsInValue` in `@linnlabs/linnkit/runtime-kernel` — provider-agnostic helpers for merging adjacent streaming `reasoning_content` fragments.
24
+
25
+ ### Fixed
26
+
27
+ - Streaming `reasoning_details` now merges adjacent pure text reasoning fragments before returning the final LLM result and before emitting provider sidecar updates, preventing audit records from storing token-by-token reasoning fragments.
28
+ - `ToolNode` now drains every pending `assistant.tool_calls` item in the current batch before returning to the LLM, even when an earlier tool call fails. This preserves the protocol invariant that each tool call receives a corresponding tool output and avoids incomplete tool-call groups being dropped by downstream context assembly.
29
+ - Tool protocol fuse handling now waits until the current batch has been fully consumed before throwing, so one repeated protocol error cannot strand later tool calls in the same assistant message.
30
+
31
+ ### Compatibility
32
+
33
+ - Minor bump because `@linnlabs/linnkit/runtime-kernel` has new public exports and ToolNode batch execution behavior is stricter.
34
+
35
+ ---
36
+
19
37
  ## [0.8.0] - 2026-05-13
20
38
 
21
39
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # linnkit
2
2
 
3
- **A fine-grained context engineering framework for Agent applications — control every token sent to the model, with clear run lifecycle, audit records, and testable protocol boundaries.**
3
+ **A fine-grained context engineering framework for Agent applications — control every token sent to the model, with clear run lifecycle, audit records, and testable protocol boundaries.** It gives a small, auditable runtime for controlling the messages, tool results, summaries, and policies that shape every LLM call.
4
4
 
5
5
  [中文文档](./README.zh-CN.md) · [Integration Guide](./docs/integration/README.md) · [Changelog](./CHANGELOG.md)
6
6
 
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # linnkit
2
2
 
3
- **linnkit 是一个可以方便地精细化管理上下文的 Agent 框架,追求控制发给LLM的每一个 token,同时保留清晰的运行生命周期、审计记录和测试边界。**
3
+ **linnkit 是一个可以方便地精细化管理上下文的 Agent 框架,追求控制发给LLM的每一个 token,同时保留清晰的运行生命周期、审计记录和测试边界。** 它提供了一种小巧、可审计的运行时环境,可对消息、工具结果、摘要及策略进行管控,而正是这些要素决定了每一次大语言模型调用的行为。
4
4
 
5
5
  [English](./README.md) · [接入文档](./docs/integration/README.md) · [更新日志](./CHANGELOG.md)
6
6
 
package/dist/cli.cjs CHANGED
@@ -1084,7 +1084,6 @@ var Logger = class {
1084
1084
  constructor(moduleName) {
1085
1085
  this.moduleName = moduleName;
1086
1086
  }
1087
- moduleName;
1088
1087
  debug(message, data) {
1089
1088
  this.log(0 /* DEBUG */, "debug", message, data);
1090
1089
  }
@@ -1166,7 +1165,6 @@ var GraphExecutor = class {
1166
1165
  };
1167
1166
  this.telemetryPort = config.telemetryPort ?? noopTelemetry;
1168
1167
  }
1169
- checkpointer;
1170
1168
  nodes = /* @__PURE__ */ new Map();
1171
1169
  ephemeralLocals = /* @__PURE__ */ new Map();
1172
1170
  config;
@@ -4448,6 +4446,22 @@ var ToolNode = class {
4448
4446
  });
4449
4447
  }
4450
4448
  async run(state) {
4449
+ const events = [];
4450
+ while (true) {
4451
+ const result = await this.runNextPendingToolCall(state);
4452
+ if (Array.isArray(result.events) && result.events.length > 0) {
4453
+ events.push(...result.events);
4454
+ }
4455
+ if (result.kind === "route" && result.nextNodeId === "tool") {
4456
+ continue;
4457
+ }
4458
+ return {
4459
+ ...result,
4460
+ events
4461
+ };
4462
+ }
4463
+ }
4464
+ async runNextPendingToolCall(state) {
4451
4465
  const calls = state.local?.pendingToolCalls ?? [];
4452
4466
  const signalRaw = state.local?.signal;
4453
4467
  if (isAbortSignal(signalRaw) && signalRaw.aborted) {
@@ -4655,18 +4669,25 @@ var ToolNode = class {
4655
4669
  rawArguments: context.call.function?.arguments,
4656
4670
  parsedArguments: context.toolArgs
4657
4671
  });
4672
+ const remainingCalls = context.calls.slice(1);
4658
4673
  context.state.local = buildErrorLocalState({
4659
4674
  local: context.local,
4660
- remainingCalls: context.calls.slice(1),
4675
+ remainingCalls,
4661
4676
  conversationId: context.conversationId,
4662
4677
  turnId: context.turnId,
4663
4678
  runtimeEvents: context.bridge.getRuntimeEvents(),
4664
4679
  nextProtocolErrorCount: fuse.nextCount
4665
4680
  });
4666
- if (fuse.shouldFuse) {
4681
+ if (fuse.shouldFuse && remainingCalls.length === 0) {
4667
4682
  throw createToolProtocolFuseError(fuse.nextCount, context.exec.error);
4668
4683
  }
4669
- return { kind: "route", nextNodeId: "llm", events: context.bridge.getRuntimeEvents() };
4684
+ return {
4685
+ kind: "route",
4686
+ // 同一个 assistant.tool_calls batch 必须为每个 call 产出 tool_output。
4687
+ // 出错时也继续消费剩余 call,ToolNode.run 会在本节点内 drain 完 batch 再回 LLM。
4688
+ nextNodeId: remainingCalls.length > 0 ? "tool" : "llm",
4689
+ events: context.bridge.getRuntimeEvents()
4690
+ };
4670
4691
  }
4671
4692
  };
4672
4693
 
@@ -5162,18 +5183,18 @@ function createClassification(category, reason, suggestedDelay, extras) {
5162
5183
  }
5163
5184
  var ErrorClassifier = class {
5164
5185
  static classify(error, context) {
5165
- const isRecord22 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5186
+ const isRecord23 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5166
5187
  const baseMsg = (error.message || "").toLowerCase();
5167
5188
  const causeMsg = (() => {
5168
5189
  const cause = error.cause;
5169
5190
  if (typeof cause === "string") return cause.toLowerCase();
5170
5191
  if (cause instanceof Error) return (cause.message || "").toLowerCase();
5171
- if (isRecord22(cause) && typeof cause["message"] === "string") return String(cause["message"]).toLowerCase();
5192
+ if (isRecord23(cause) && typeof cause["message"] === "string") return String(cause["message"]).toLowerCase();
5172
5193
  return "";
5173
5194
  })();
5174
5195
  const causeCode = (() => {
5175
5196
  const cause = error.cause;
5176
- if (isRecord22(cause) && typeof cause["code"] === "string") return String(cause["code"]);
5197
+ if (isRecord23(cause) && typeof cause["code"] === "string") return String(cause["code"]);
5177
5198
  return "";
5178
5199
  })();
5179
5200
  const errorMessage = `${baseMsg} ${causeMsg}`.trim();
@@ -5849,6 +5870,67 @@ function assertToolCallsHaveValidJsonArguments(toolCalls) {
5849
5870
  }
5850
5871
  }
5851
5872
 
5873
+ // src/runtime-kernel/llm/reasoning-details.ts
5874
+ var mergeableTextFields = ["reasoning_content"];
5875
+ function isRecord18(value) {
5876
+ return !!value && typeof value === "object" && !Array.isArray(value);
5877
+ }
5878
+ function findMergeableTextField(detail) {
5879
+ if (!isRecord18(detail)) return void 0;
5880
+ if (typeof detail.provider !== "string") return void 0;
5881
+ if (typeof detail.type !== "string") return void 0;
5882
+ for (const field of mergeableTextFields) {
5883
+ if (typeof detail[field] === "string") {
5884
+ return field;
5885
+ }
5886
+ }
5887
+ return void 0;
5888
+ }
5889
+ function hasOnlyStableTextDetailFields(detail, textField) {
5890
+ const allowedKeys = /* @__PURE__ */ new Set(["provider", "type", textField]);
5891
+ return Object.keys(detail).every((key) => allowedKeys.has(key));
5892
+ }
5893
+ function canMergeTextDetails(previous, incoming) {
5894
+ if (!isRecord18(previous) || !isRecord18(incoming)) return false;
5895
+ const previousField = findMergeableTextField(previous);
5896
+ const incomingField = findMergeableTextField(incoming);
5897
+ if (!previousField || previousField !== incomingField) return false;
5898
+ return previous.provider === incoming.provider && previous.type === incoming.type && hasOnlyStableTextDetailFields(previous, previousField) && hasOnlyStableTextDetailFields(incoming, incomingField);
5899
+ }
5900
+ function mergeStreamingText(previous, incoming) {
5901
+ if (!previous) return incoming;
5902
+ if (!incoming) return previous;
5903
+ if (incoming.startsWith(previous)) {
5904
+ return incoming;
5905
+ }
5906
+ if (previous.endsWith(incoming)) {
5907
+ return previous;
5908
+ }
5909
+ return `${previous}${incoming}`;
5910
+ }
5911
+ function appendStreamingProviderReasoningDetails(existing, incoming) {
5912
+ const next = [...existing];
5913
+ for (const detail of incoming) {
5914
+ const previous = next[next.length - 1];
5915
+ if (canMergeTextDetails(previous, detail)) {
5916
+ const textField = findMergeableTextField(previous);
5917
+ if (textField && isRecord18(detail)) {
5918
+ const mergedText = mergeStreamingText(String(previous[textField]), String(detail[textField]));
5919
+ if (mergedText === previous[textField]) {
5920
+ continue;
5921
+ }
5922
+ next[next.length - 1] = {
5923
+ ...previous,
5924
+ [textField]: mergedText
5925
+ };
5926
+ continue;
5927
+ }
5928
+ }
5929
+ next.push(detail);
5930
+ }
5931
+ return next;
5932
+ }
5933
+
5852
5934
  // src/runtime-kernel/llm/streaming-adapter.ts
5853
5935
  async function callLlmStream(params) {
5854
5936
  const {
@@ -5861,7 +5943,7 @@ async function callLlmStream(params) {
5861
5943
  } = params;
5862
5944
  let fullResponse = "";
5863
5945
  let streamError = null;
5864
- const reasoningDetails = [];
5946
+ let reasoningDetails = [];
5865
5947
  const streamAnswerId = generateMessageId();
5866
5948
  let streamChunkSeq = 0;
5867
5949
  let capturedUsage = void 0;
@@ -5931,13 +6013,21 @@ async function callLlmStream(params) {
5931
6013
  const reasoning = isRecord17(chunk) ? chunk["reasoning_details"] : void 0;
5932
6014
  if (reasoning !== void 0) {
5933
6015
  const newReasoningDetails = Array.isArray(reasoning) ? reasoning : [reasoning];
5934
- reasoningDetails.push(...newReasoningDetails);
5935
- eventHandler({
5936
- type: "provider_sidecar",
5937
- id: generateMessageId(),
5938
- timestamp: Date.now(),
5939
- reasoning_details: newReasoningDetails
5940
- });
6016
+ const previousReasoningDetails = reasoningDetails;
6017
+ const previousLength = previousReasoningDetails.length;
6018
+ const compactedReasoningDetails = appendStreamingProviderReasoningDetails(reasoningDetails, newReasoningDetails);
6019
+ reasoningDetails = compactedReasoningDetails;
6020
+ const previousLastChanged = previousLength > 0 && compactedReasoningDetails[previousLength - 1] !== previousReasoningDetails[previousLength - 1];
6021
+ const emitFromIndex = previousLastChanged ? previousLength - 1 : previousLength;
6022
+ const emittedReasoningDetails = compactedReasoningDetails.slice(Math.max(0, emitFromIndex));
6023
+ if (emittedReasoningDetails.length > 0) {
6024
+ eventHandler({
6025
+ type: "provider_sidecar",
6026
+ id: generateMessageId(),
6027
+ timestamp: Date.now(),
6028
+ reasoning_details: emittedReasoningDetails
6029
+ });
6030
+ }
5941
6031
  }
5942
6032
  if (chunk.tool_calls) {
5943
6033
  emitThoughtComplete(thoughtSegmenter.onBoundary());
@@ -6508,7 +6598,7 @@ function runRecordToMeta(record) {
6508
6598
  errorIfAny: record.errorIfAny ? { ...record.errorIfAny } : void 0
6509
6599
  };
6510
6600
  }
6511
- function isRecord18(value) {
6601
+ function isRecord19(value) {
6512
6602
  return typeof value === "object" && value !== null && !Array.isArray(value);
6513
6603
  }
6514
6604
  function readStringField(record, key) {
@@ -6529,7 +6619,7 @@ function getRunIdFromMetadata(event) {
6529
6619
  return snakeCaseRunId;
6530
6620
  }
6531
6621
  const runContext = metadata["run_context"];
6532
- if (isRecord18(runContext)) {
6622
+ if (isRecord19(runContext)) {
6533
6623
  return readStringField(runContext, "runId") ?? readStringField(runContext, "run_id");
6534
6624
  }
6535
6625
  return void 0;
@@ -6747,7 +6837,7 @@ function runMetaFromRecord(record) {
6747
6837
  }
6748
6838
 
6749
6839
  // src/runtime-kernel/run-supervisor/runSupervisor.ts
6750
- function isRecord19(value) {
6840
+ function isRecord20(value) {
6751
6841
  return typeof value === "object" && value !== null && !Array.isArray(value);
6752
6842
  }
6753
6843
  function readStringField2(record, key) {
@@ -6764,7 +6854,7 @@ function readRunIdFromRuntimeEvent(event) {
6764
6854
  return directRunId;
6765
6855
  }
6766
6856
  const runContext = metadata["run_context"];
6767
- if (!isRecord19(runContext)) {
6857
+ if (!isRecord20(runContext)) {
6768
6858
  return void 0;
6769
6859
  }
6770
6860
  return readStringField2(runContext, "runId") ?? readStringField2(runContext, "run_id");
@@ -6776,7 +6866,7 @@ function readAwaitingUserReason(event) {
6776
6866
  if (typeof event.prompt === "string" && event.prompt.trim().length > 0) {
6777
6867
  return event.prompt;
6778
6868
  }
6779
- if (isRecord19(event.form)) {
6869
+ if (isRecord20(event.form)) {
6780
6870
  const prompt = readStringField2(event.form, "prompt");
6781
6871
  if (prompt && prompt.trim().length > 0) {
6782
6872
  return prompt;
@@ -7386,7 +7476,7 @@ function createQuickstartTelemetryPort(collector) {
7386
7476
  }
7387
7477
 
7388
7478
  // src/quickstart/runAgent.ts
7389
- function isRecord20(value) {
7479
+ function isRecord21(value) {
7390
7480
  return typeof value === "object" && value !== null && !Array.isArray(value);
7391
7481
  }
7392
7482
  function readString4(value) {
@@ -7400,7 +7490,7 @@ function resolveModelId(agent, options) {
7400
7490
  return modelId;
7401
7491
  }
7402
7492
  function isQuickstartStreamChunkEvent(value) {
7403
- return isRecord20(value) && value.type === "stream_chunk" && typeof value.content === "string";
7493
+ return isRecord21(value) && value.type === "stream_chunk" && typeof value.content === "string";
7404
7494
  }
7405
7495
  function createNoopObservationPreview() {
7406
7496
  return {
@@ -7440,7 +7530,7 @@ function readFinalAnswer(events, checkpointLocal) {
7440
7530
  if (chunks.length > 0) {
7441
7531
  return chunks.join("");
7442
7532
  }
7443
- if (isRecord20(checkpointLocal)) {
7533
+ if (isRecord21(checkpointLocal)) {
7444
7534
  const finalAnswer = checkpointLocal["finalAnswer"];
7445
7535
  if (typeof finalAnswer === "string") {
7446
7536
  return finalAnswer;
@@ -7449,7 +7539,7 @@ function readFinalAnswer(events, checkpointLocal) {
7449
7539
  return "";
7450
7540
  }
7451
7541
  function readContextTrace(checkpointLocal) {
7452
- if (!isRecord20(checkpointLocal)) return void 0;
7542
+ if (!isRecord21(checkpointLocal)) return void 0;
7453
7543
  return checkpointLocal["contextTrace"];
7454
7544
  }
7455
7545
  async function emitRunEvent(event, sink) {
@@ -7573,11 +7663,11 @@ async function runAgent(agent, options) {
7573
7663
  }
7574
7664
 
7575
7665
  // src/cli/configLoader.ts
7576
- function isRecord21(value) {
7666
+ function isRecord22(value) {
7577
7667
  return typeof value === "object" && value !== null && !Array.isArray(value);
7578
7668
  }
7579
7669
  function readDefaultExport(moduleValue) {
7580
- if (isRecord21(moduleValue) && "default" in moduleValue) {
7670
+ if (isRecord22(moduleValue) && "default" in moduleValue) {
7581
7671
  return moduleValue.default;
7582
7672
  }
7583
7673
  return moduleValue;
@@ -7588,7 +7678,7 @@ async function loadConfig(configPath, cwd) {
7588
7678
  moduleUrl.searchParams.set("t", String(Date.now()));
7589
7679
  const loaded = await import(moduleUrl.href);
7590
7680
  const config = readDefaultExport(loaded);
7591
- if (!isRecord21(config)) {
7681
+ if (!isRecord22(config)) {
7592
7682
  throw new Error(`[linnkit] config must export an object: ${absolutePath}`);
7593
7683
  }
7594
7684
  return defineConfig(config);