@netnodeag/kraftwerk 0.50.0 → 0.50.1

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.
@@ -476,7 +476,13 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
476
476
  _meta: fileChangeReportMeta(randomUUID()),
477
477
  });
478
478
  turns++;
479
- return res.stopReason;
479
+ // A provider condition that ends the turn itself (usage limit, rate
480
+ // limit, provider error) is not a notification: the adapter settles
481
+ // the prompt with stopReason "end_turn" and the typed failure in the
482
+ // response `_meta`. Without reading it here the turn would end
483
+ // silently — no text, no error (see failureOf).
484
+ const failure = failureOf(res._meta);
485
+ return failure ? { stopReason: res.stopReason, failure } : { stopReason: res.stopReason };
480
486
  }
481
487
  catch (err) {
482
488
  // A resumed session that cannot even take its first prompt (the
@@ -1,4 +1,4 @@
1
- import type { AgentCommand, Attachment, ChatEvent, ConfigOption, ElicitationAnswer, ElicitationField } from "./types.js";
1
+ import type { AgentCommand, Attachment, ChatEvent, ConfigOption, ElicitationAnswer, ElicitationField, SessionFailure } from "./types.js";
2
2
  /** An attachment as the backend gets it: where the file is, and what it is. */
3
3
  export interface PromptFile extends Attachment {
4
4
  path: string;
@@ -46,6 +46,17 @@ export interface BackendTuning {
46
46
  /** ACP agents: the session id to continue (from ChatMeta.sessions) instead of opening a new one. */
47
47
  resume?: string;
48
48
  }
49
+ /**
50
+ * How a turn ended. `failure` is the typed session failure the agent
51
+ * attached to the turn's own outcome (a usage limit, a rate limit, a
52
+ * provider error): ACP hands those back on the prompt response rather
53
+ * than as a mid-turn notification, and without it the turn would look
54
+ * like an ordinary empty `end_turn`.
55
+ */
56
+ export interface TurnEnd {
57
+ stopReason: string;
58
+ failure?: SessionFailure;
59
+ }
49
60
  export interface ChatBackend {
50
61
  /** The agent's own session id, when it has one to resume later (ACP agents). */
51
62
  sessionId?: string;
@@ -53,8 +64,8 @@ export interface ChatBackend {
53
64
  resumed?: boolean;
54
65
  /** The resumed session turned out unusable at its first prompt — its id must not be resumed again. */
55
66
  resumeFailed?: boolean;
56
- /** Send one user message (plus files dropped into it); resolves with the stop reason at turn end. */
57
- prompt(text: string, files?: PromptFile[]): Promise<string>;
67
+ /** Send one user message (plus files dropped into it); resolves with how the turn ended. */
68
+ prompt(text: string, files?: PromptFile[]): Promise<TurnEnd>;
58
69
  /** Branch the conversation: a new session id with this session's history (ACP agents that offer session/fork). */
59
70
  fork?(): Promise<string>;
60
71
  /**
@@ -87,14 +87,14 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
87
87
  child = null;
88
88
  handleLine(buffer);
89
89
  if (cancelled)
90
- return resolve("cancelled");
90
+ return resolve({ stopReason: "cancelled" });
91
91
  if (failure || code !== 0 || !sawAssistant) {
92
92
  reject(new Error(`pi failed (exit ${code})` +
93
93
  (failure ? `: ${failure}` : "") +
94
94
  (stderr.trim() ? `\nstderr: ${stderr.trim().slice(-1000)}` : "")));
95
95
  return;
96
96
  }
97
- resolve("end_turn");
97
+ resolve({ stopReason: "end_turn" });
98
98
  });
99
99
  });
100
100
  },
@@ -67,6 +67,10 @@ function lastEventText(state, afterSeq, type) {
67
67
  parts.push(e.text);
68
68
  if (type === "error" && e.type === "error")
69
69
  parts.push(e.message);
70
+ // A turn the agent ended on a provider failure (usage limit, rate
71
+ // limit) reports no error event — the failure card is the outcome.
72
+ if (type === "error" && e.type === "failure" && e.severity === "error")
73
+ parts.push(e.title);
70
74
  }
71
75
  const text = parts.join("").trim();
72
76
  if (!text)
@@ -790,14 +794,18 @@ export async function postMessage(id, text, opts = {}) {
790
794
  // the thread keeps the short form the user typed.
791
795
  const body = await expandSkillInvocation(state.meta.scope, text);
792
796
  const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
793
- const stopReason = await backend.prompt(promptText, promptFiles(id, attachments));
797
+ const { stopReason, failure } = await backend.prompt(promptText, promptFiles(id, attachments));
794
798
  emit(state, { type: "turn_end", stopReason });
799
+ // After turn_end, so the card stays live (a turn_end settles the
800
+ // failures reported before it): this one is why the turn ended.
801
+ if (failure)
802
+ emit(state, { type: "failure", ...failure });
795
803
  // Routine sessions are one-shot and unattended: release the agent
796
804
  // process as soon as the turn ends instead of waiting for the reaper,
797
805
  // and tell the bell how it went.
798
806
  if (isUnattended(state.meta.scope)) {
799
807
  dropBackend(state);
800
- const failed = state.events.some((e) => e.seq > turnStart && e.type === "error");
808
+ const failed = state.events.some((e) => e.seq > turnStart && (e.type === "error" || (e.type === "failure" && e.severity === "error")));
801
809
  void pushNotification({
802
810
  kind: failed ? "routine_failed" : "routine_done",
803
811
  title: `${chatLabel(state.meta)} ${failed ? "ended with an error" : "finished"}`,
@@ -923,8 +931,10 @@ async function runSeatTurn(state, channel, seat, hops) {
923
931
  }
924
932
  const prompt = (context ? `<context>\n${context}\n</context>\n\n` : "") +
925
933
  (delta ? `New messages in #${channel.slug}:\n\n${delta}` : `You joined #${channel.slug}. Nothing addressed to you yet — reply with one short line saying you are here.`);
926
- const stopReason = await backend.prompt(prompt);
934
+ const { stopReason, failure } = await backend.prompt(prompt);
927
935
  emit(state, { type: "turn_end", stopReason }, me);
936
+ if (failure)
937
+ emit(state, { type: "failure", ...failure }, me);
928
938
  // Handover: agents this one @mentioned get its message, within the hop budget.
929
939
  const said = seatTextSince(state, start, seat.key);
930
940
  const next = mentionTargets(said, channel, seat.key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.50.0",
3
+ "version": "0.50.1",
4
4
  "description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
5
5
  "keywords": [
6
6
  "agents",