@schlenkr/ragents 0.1.6 → 0.1.8

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.
Files changed (150) hide show
  1. package/apps/server/src/chat-handler.ts +2 -0
  2. package/apps/server/src/config.ts +6 -0
  3. package/apps/server/src/host-api.json +1 -0
  4. package/apps/server/src/layout.ts +0 -15
  5. package/apps/server/src/plugin-support/agent-tool.ts +2 -3
  6. package/apps/server/src/plugin-support/plugin-folder.ts +2 -1
  7. package/apps/server/src/provider.ts +34 -11
  8. package/apps/server/src/ragents/actor-chat-history.ts +1 -0
  9. package/apps/server/src/ragents/engine.ts +23 -24
  10. package/apps/server/src/ragents/journal-chat-events.ts +2 -0
  11. package/apps/server/src/ragents/run-owner.ts +3 -2
  12. package/apps/server/src/ragents/session.ts +3 -3
  13. package/apps/server/src/run-preparation.ts +14 -18
  14. package/apps/server/src/run-transfer.ts +65 -4
  15. package/apps/server/src/settings.ts +2 -4
  16. package/apps/web/dist/assets/{highlighted-body-KPVGNVTW-B9qYBARS.js → highlighted-body-KPVGNVTW-xXXhTDbh.js} +1 -1
  17. package/apps/web/dist/assets/{main-BCjvD2Hh.js → main-C7JeZFjs.js} +1 -1
  18. package/apps/web/dist/assets/{mermaid-HWGCJPDP-CvYYFK_n.js → mermaid-HWGCJPDP-DfsU5ofC.js} +69 -69
  19. package/apps/web/dist/assets/{runPanel-CG7kRMJh.js → runPanel-ZT6TPDLM.js} +1 -1
  20. package/apps/web/dist/help/guide-clients.html +13 -4
  21. package/apps/web/dist/help/guide-clients.md +14 -4
  22. package/apps/web/dist/help/guide-distributed.html +8 -3
  23. package/apps/web/dist/help/guide-distributed.md +8 -3
  24. package/apps/web/dist/help/guide-runtime.html +18 -12
  25. package/apps/web/dist/help/guide-runtime.md +19 -12
  26. package/apps/web/dist/help/mini-app.css +23 -3
  27. package/apps/web/dist/host-web.json +4 -4
  28. package/apps/web/dist/index.html +2 -2
  29. package/apps/web/dist/run-panel.html +2 -2
  30. package/apps/web/src/SessionList.tsx +16 -8
  31. package/apps/web/src/StartTiles.tsx +1 -1
  32. package/apps/web/src/api.ts +2 -0
  33. package/apps/web/src/panel/PanelPage.tsx +1 -1
  34. package/apps/web/src/panel/RunLine.tsx +4 -2
  35. package/apps/web/src/panel/contract.ts +2 -0
  36. package/bundles/ragents.activity/ragents-bundle.json +1 -1
  37. package/bundles/ragents.actor-programs/ragents-bundle.json +1 -1
  38. package/bundles/ragents.ask/ragents-bundle.json +1 -1
  39. package/bundles/ragents.browser/ragents-bundle.json +1 -1
  40. package/bundles/ragents.documents/ragents-bundle.json +1 -1
  41. package/bundles/ragents.lsp-fsharp/ragents-bundle.json +1 -1
  42. package/bundles/ragents.lsp-roslyn/ragents-bundle.json +1 -1
  43. package/bundles/ragents.lsp-typescript/ragents-bundle.json +1 -1
  44. package/bundles/ragents.model-relay/ragents-bundle.json +1 -1
  45. package/bundles/ragents.orchestration/ragents-bundle.json +1 -1
  46. package/bundles/ragents.overseer/ragents-bundle.json +1 -1
  47. package/bundles/ragents.processes/ragents-bundle.json +1 -1
  48. package/bundles/ragents.product/ragents-bundle.json +1 -1
  49. package/bundles/ragents.profile-distribution/ragents-bundle.json +1 -1
  50. package/bundles/ragents.reference/ragents-bundle.json +2 -2
  51. package/bundles/ragents.reference/run-scripts/learning-afternoon/tests/program.test.ts +1 -1
  52. package/bundles/ragents.reference/run-scripts/word-game/tests/program.test.ts +3 -3
  53. package/bundles/ragents.todo/ragents-bundle.json +1 -1
  54. package/bundles/ragents.transcript/ragents-bundle.json +1 -1
  55. package/bundles/ragents.watch/ragents-bundle.json +1 -1
  56. package/bundles/ragents.workspace/ragents-bundle.json +1 -1
  57. package/package.json +2 -2
  58. package/packages/agent/dist/core/agent-settings.d.ts +2 -2
  59. package/packages/agent/dist/core/compaction/compaction.d.ts +16 -50
  60. package/packages/agent/dist/core/context-log.d.ts +25 -0
  61. package/packages/agent/dist/core/messages.d.ts +3 -10
  62. package/packages/agent/dist/core/tool-definition.d.ts +17 -0
  63. package/packages/agent/dist/core/tools/bash.d.ts +2 -7
  64. package/packages/agent/dist/core/tools/edit.d.ts +1 -1
  65. package/packages/agent/dist/core/tools/read.d.ts +1 -1
  66. package/packages/agent/dist/core/tools/write.d.ts +1 -1
  67. package/packages/agent/dist/index.d.ts +7 -8
  68. package/packages/agent/dist/loop/agent.d.ts +2 -6
  69. package/packages/agent/package.json +1 -1
  70. package/packages/agent/src/core/agent-settings.ts +2 -2
  71. package/packages/agent/src/core/compaction/compaction.ts +49 -175
  72. package/packages/agent/src/core/context-log.ts +49 -0
  73. package/packages/agent/src/core/messages.ts +5 -11
  74. package/packages/agent/src/core/tool-definition.ts +29 -0
  75. package/packages/agent/src/core/tools/bash.ts +2 -9
  76. package/packages/agent/src/core/tools/edit.ts +2 -11
  77. package/packages/agent/src/core/tools/read.ts +3 -18
  78. package/packages/agent/src/core/tools/write.ts +1 -5
  79. package/packages/agent/src/index.ts +28 -14
  80. package/packages/agent/src/loop/agent-loop.ts +1 -2
  81. package/packages/agent/src/loop/agent.ts +8 -2
  82. package/packages/ai/dist/api/ai-sdk.d.ts +2 -0
  83. package/packages/ai/dist/types.d.ts +2 -0
  84. package/packages/ai/src/api/ai-sdk-messages.ts +15 -10
  85. package/packages/ai/src/api/ai-sdk.ts +11 -0
  86. package/packages/ai/src/api/transform-messages.ts +1 -1
  87. package/packages/ai/src/types.ts +2 -0
  88. package/packages/ai/src/utils/retry.ts +14 -0
  89. package/packages/ragents/src/agents/actor-input.ts +15 -0
  90. package/packages/ragents/src/agents/model-context.ts +360 -0
  91. package/packages/ragents/src/agents/plugins.ts +5 -0
  92. package/packages/ragents/src/agents/scheduler.ts +3 -7
  93. package/packages/ragents/src/agents/tools.ts +2 -2
  94. package/packages/ragents/src/agents/toolset.ts +47 -20
  95. package/packages/ragents/src/agents/workspaces.ts +0 -6
  96. package/packages/ragents/src/domain/event-semantics.ts +51 -0
  97. package/packages/ragents/src/domain/event-validation.ts +166 -0
  98. package/packages/ragents/src/domain/events.ts +72 -0
  99. package/packages/ragents/src/domain/model.ts +4 -2
  100. package/packages/ragents/src/domain/projection.ts +9 -0
  101. package/packages/ragents/src/domain/tool-result-text.ts +9 -0
  102. package/packages/ragents/src/drivers/agent-hooks.ts +25 -38
  103. package/packages/ragents/src/drivers/agent-runtime.ts +118 -933
  104. package/packages/ragents/src/drivers/agent-turn.ts +735 -0
  105. package/packages/ragents/src/drivers/agent.ts +9 -7
  106. package/packages/ragents/src/drivers/skill-preload.ts +53 -63
  107. package/packages/ragents/src/drivers/types.ts +21 -5
  108. package/packages/ragents/src/index.ts +8 -6
  109. package/packages/ragents/src/plugin-host.ts +60 -13
  110. package/packages/ragents/src/plugin-types.ts +7 -2
  111. package/packages/ragents/src/runtime/decisions/actors.ts +10 -0
  112. package/packages/ragents/src/runtime/decisions/turns.ts +66 -7
  113. package/packages/ragents/src/runtime/journal-storage.ts +7 -4
  114. package/packages/ragents/src/runtime/journal.ts +35 -0
  115. package/packages/ragents/src/runtime/orchestration.ts +140 -9
  116. package/packages/workspace-executor/src/sandbox-tools.ts +10 -11
  117. package/plugins/ragents.reference/run-scripts/learning-afternoon/tests/program.test.ts +1 -1
  118. package/plugins/ragents.reference/run-scripts/word-game/tests/program.test.ts +3 -3
  119. package/scripts/agent/journal.ts +5 -3
  120. package/scripts/package/publish-package.ts +4 -3
  121. package/packages/agent/dist/core/agent-session-runtime.d.ts +0 -33
  122. package/packages/agent/dist/core/agent-session-services.d.ts +0 -35
  123. package/packages/agent/dist/core/agent-session.d.ts +0 -234
  124. package/packages/agent/dist/core/auth-guidance.d.ts +0 -4
  125. package/packages/agent/dist/core/defaults.d.ts +0 -3
  126. package/packages/agent/dist/core/extensions/index.d.ts +0 -11
  127. package/packages/agent/dist/core/extensions/loader.d.ts +0 -12
  128. package/packages/agent/dist/core/extensions/runner.d.ts +0 -39
  129. package/packages/agent/dist/core/extensions/types.d.ts +0 -156
  130. package/packages/agent/dist/core/extensions/wrapper.d.ts +0 -12
  131. package/packages/agent/dist/core/resource-loader.d.ts +0 -39
  132. package/packages/agent/dist/core/sdk.d.ts +0 -38
  133. package/packages/agent/dist/core/session-manager.d.ts +0 -189
  134. package/packages/agent/dist/core/source-info.d.ts +0 -16
  135. package/packages/agent/dist/core/system-prompt.d.ts +0 -21
  136. package/packages/agent/src/core/agent-session-runtime.ts +0 -94
  137. package/packages/agent/src/core/agent-session-services.ts +0 -74
  138. package/packages/agent/src/core/agent-session.ts +0 -1108
  139. package/packages/agent/src/core/auth-guidance.ts +0 -14
  140. package/packages/agent/src/core/defaults.ts +0 -3
  141. package/packages/agent/src/core/extensions/index.ts +0 -38
  142. package/packages/agent/src/core/extensions/loader.ts +0 -125
  143. package/packages/agent/src/core/extensions/runner.ts +0 -211
  144. package/packages/agent/src/core/extensions/types.ts +0 -197
  145. package/packages/agent/src/core/extensions/wrapper.ts +0 -24
  146. package/packages/agent/src/core/resource-loader.ts +0 -142
  147. package/packages/agent/src/core/sdk.ts +0 -123
  148. package/packages/agent/src/core/session-manager.ts +0 -743
  149. package/packages/agent/src/core/source-info.ts +0 -28
  150. package/packages/agent/src/core/system-prompt.ts +0 -108
@@ -1,20 +1,23 @@
1
1
  // Everything the engine, the server and the workspace executor use; the rest is internal.
2
- export type { AgentSession } from "./core/agent-session.ts";
3
- export type { ExtensionAPI, ExtensionContext, ExtensionError, InlineExtension, ToolDefinition } from "./core/extensions/index.ts";
4
- export { defineTool } from "./core/extensions/index.ts";
5
2
  export { aliasedModel, type ModelAlias, ModelRuntime } from "./core/model-runtime.ts";
6
- export { DefaultResourceLoader } from "./core/resource-loader.ts";
3
+ export { type AgentSettings, type AgentSettingsInput, agentSettings } from "./core/agent-settings.ts";
7
4
  export {
8
- type AgentSessionRuntime,
9
- type CreateAgentSessionRuntimeFactory,
10
- createAgentSession,
11
- createAgentSessionFromServices,
12
- createAgentSessionRuntime,
13
- createAgentSessionServices,
14
- } from "./core/sdk.ts";
15
- export { CURRENT_SESSION_VERSION, type SessionEntry, SessionManager } from "./core/session-manager.ts";
16
- export type { AgentSettingsInput } from "./core/agent-settings.ts";
17
- export type { Skill } from "./core/skills.ts";
5
+ activeContextEntries,
6
+ type ContextCompaction,
7
+ type ContextLogEntry,
8
+ contextMessages,
9
+ latestCompaction,
10
+ } from "./core/context-log.ts";
11
+ export {
12
+ calculateContextTokens,
13
+ compact,
14
+ estimateContextTokens,
15
+ prepareCompaction,
16
+ shouldCompact,
17
+ } from "./core/compaction/index.ts";
18
+ export { convertToLlm } from "./core/messages.ts";
19
+ export { formatSkillsForPrompt, type Skill } from "./core/skills.ts";
20
+ export { defineTool, type ToolDefinition } from "./core/tool-definition.ts";
18
21
  export {
19
22
  type BashOperations,
20
23
  createBashToolDefinition,
@@ -22,5 +25,16 @@ export {
22
25
  createReadToolDefinition,
23
26
  createWriteToolDefinition,
24
27
  } from "./core/tools/index.ts";
28
+ export {
29
+ Agent,
30
+ type AgentEvent,
31
+ type AgentLoopTurnUpdate,
32
+ type AgentMessage,
33
+ type AgentTool,
34
+ type AgentToolResult,
35
+ EMPTY_RESPONSE_NUDGE,
36
+ isRunFailure,
37
+ type ThinkingLevel,
38
+ } from "./loop/index.ts";
25
39
  export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts";
26
40
  export { getShellConfig, killProcessTree, type ShellConfig } from "./utils/shell.ts";
@@ -715,8 +715,7 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
715
715
  role: "toolResult",
716
716
  toolCallId: finalized.toolCall.id,
717
717
  toolName: finalized.toolCall.name,
718
- // Untyped tools (JS extensions) can return results without content; normalize
719
- // so the null never enters session history or provider payloads.
718
+ // Untyped tools can return results without content; the null never enters the context.
720
719
  content: finalized.result.content ?? [],
721
720
  details: finalized.result.details,
722
721
  ...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
@@ -123,6 +123,11 @@ type ActiveRun = {
123
123
  * `Agent` owns the current transcript, emits lifecycle events, executes tools,
124
124
  * and polls its steering source before each model request.
125
125
  */
126
+ const runFailures = new WeakSet<object>();
127
+
128
+ /** Whether a message is the stand-in the agent reports for a failed run; no model produced it. */
129
+ export const isRunFailure = (message: AgentMessage): boolean => runFailures.has(message);
130
+
126
131
  export class Agent {
127
132
  private _state: MutableAgentState;
128
133
  private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();
@@ -363,7 +368,7 @@ export class Agent {
363
368
  }
364
369
 
365
370
  private async handleRunFailure(error: unknown, aborted: boolean): Promise<void> {
366
- const failureMessage = {
371
+ const failureMessage: AgentMessage = {
367
372
  role: "assistant",
368
373
  content: [{ type: "text", text: "" }],
369
374
  api: this._state.model.api,
@@ -373,7 +378,8 @@ export class Agent {
373
378
  stopReason: aborted ? "aborted" : "error",
374
379
  errorMessage: error instanceof Error ? error.message : String(error),
375
380
  timestamp: Date.now(),
376
- } satisfies AgentMessage;
381
+ };
382
+ runFailures.add(failureMessage);
377
383
  await this.processEvents({ type: "message_start", message: failureMessage });
378
384
  await this.processEvents({ type: "message_end", message: failureMessage });
379
385
  await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] });
@@ -9,5 +9,7 @@ export interface OpenRouterOptions extends StreamOptions {
9
9
  reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
10
10
  }
11
11
  export declare const stream: StreamFunction<"openai-completions", OpenRouterOptions>;
12
+ /** Counts cache_control blocks anywhere in the request body, as the provider counts them. */
13
+ export declare const cacheMarksOf: (value: unknown) => number;
12
14
  export declare const streamSimple: StreamFunction<"openai-completions", SimpleStreamOptions>;
13
15
  //# sourceMappingURL=ai-sdk.d.ts.map
@@ -211,6 +211,8 @@ export interface UserMessage {
211
211
  role: "user";
212
212
  content: string | UserContent[];
213
213
  timestamp: number;
214
+ /** A note for this one request only; the cache boundary stays in front of it. */
215
+ transient?: true;
214
216
  }
215
217
  export interface AssistantMessage {
216
218
  role: "assistant";
@@ -1,4 +1,4 @@
1
- import type { AssistantModelMessage, FilePart, JSONValue, ModelMessage, UserContent as SdkUserContent } from "ai";
1
+ import type { AssistantModelMessage, FilePart, JSONValue, ModelMessage, ToolResultPart, UserContent as SdkUserContent } from "ai";
2
2
  import type { Api, AssistantMessage, Context, Model, UserContent } from "../types.ts";
3
3
  import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
4
4
  import { transformMessages } from "./transform-messages.ts";
@@ -54,21 +54,33 @@ function assistantMessage(message: AssistantMessage): AssistantModelMessage | un
54
54
  };
55
55
  }
56
56
 
57
+ /** The breakpoint sits on the last lasting block; on a part, because the SDK merges tool messages and copies a message mark onto every result. */
58
+ function markCacheBoundary(message: ModelMessage | undefined, cacheControl: CacheControl): void {
59
+ if (!message || message.role === "system") return;
60
+ const lastPart = message.role === "tool" ? message.content.filter((part): part is ToolResultPart => part.type === "tool-result").at(-1)
61
+ : message.role === "user" && Array.isArray(message.content) ? message.content.at(-1) : undefined;
62
+ const target = lastPart ?? message;
63
+ target.providerOptions = { ...target.providerOptions, openrouter: { ...target.providerOptions?.openrouter, cacheControl } };
64
+ }
65
+
57
66
  export function convertMessages(context: Context, model: Model<Api>, cacheControl?: CacheControl): ModelMessage[] {
58
67
  const result: ModelMessage[] = [];
59
68
  if (context.systemPrompt) result.push({ role: "system", content: sanitizeSurrogates(context.systemPrompt),
60
69
  ...(cacheControl ? { providerOptions: { openrouter: { cacheControl } } } : {}),
61
70
  });
62
71
  const messages = transformMessages(context.messages, model);
72
+ let boundary: ModelMessage | undefined;
63
73
  for (let index = 0; index < messages.length; index += 1) {
64
74
  const message = messages[index]!;
65
75
  if (message.role === "user") {
66
76
  const content = typeof message.content === "string" ? sanitizeSurrogates(message.content)
67
77
  : message.content.filter((part) => part.type !== "text" || part.text.length > 0).map(userContent);
68
78
  if (typeof content === "string" || content.length > 0) result.push({ role: "user", content });
79
+ if (!message.transient) boundary = result.at(-1);
69
80
  } else if (message.role === "assistant") {
70
81
  const converted = assistantMessage(message);
71
82
  if (converted) result.push(converted);
83
+ boundary = result.at(-1);
72
84
  } else {
73
85
  const images: FilePart[] = [];
74
86
  while (messages[index]?.role === "toolResult") {
@@ -87,16 +99,9 @@ export function convertMessages(context: Context, model: Model<Api>, cacheContro
87
99
  }
88
100
  index -= 1;
89
101
  if (images.length > 0) result.push({ role: "user", content: [{ type: "text", text: "Attached image(s) from tool result:" }, ...images] });
102
+ boundary = result.at(-1);
90
103
  }
91
104
  }
92
- if (cacheControl) {
93
- const lastMessage = [...result].reverse().find((message) => (message.role === "user" || message.role === "assistant")
94
- && (typeof message.content === "string" ? message.content.length > 0
95
- : message.content.some((part) => part.type === "text")));
96
- if (lastMessage) lastMessage.providerOptions = {
97
- ...lastMessage.providerOptions,
98
- openrouter: { ...lastMessage.providerOptions?.openrouter, cacheControl },
99
- };
100
- }
105
+ if (cacheControl) markCacheBoundary(boundary, cacheControl);
101
106
  return result;
102
107
  }
@@ -261,8 +261,19 @@ function applyRequestCompatibility(payload: unknown, model: Model<"openai-comple
261
261
  }
262
262
  if (cacheControl && body.tools?.length) body.tools[body.tools.length - 1].cache_control = cacheControl;
263
263
  if (!body.tools && body.messages.some((message) => message.role === "tool")) body.tools = [];
264
+ const marks = cacheMarksOf(body);
265
+ if (marks > MAX_CACHE_MARKS) throw new Error(`The request carries ${marks} cache marks; Anthropic accepts at most ${MAX_CACHE_MARKS}.`);
264
266
  }
265
267
 
268
+ const MAX_CACHE_MARKS = 4;
269
+
270
+ /** Counts cache_control blocks anywhere in the request body, as the provider counts them. */
271
+ export const cacheMarksOf = (value: unknown): number => {
272
+ if (Array.isArray(value)) return value.reduce((sum: number, item) => sum + cacheMarksOf(item), 0);
273
+ if (!value || typeof value !== "object") return 0;
274
+ return Object.entries(value).reduce((sum, [key, item]) => sum + (key === "cache_control" && item ? 1 : cacheMarksOf(item)), 0);
275
+ };
276
+
266
277
  export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOptions> = (model, context, options) => {
267
278
  const reasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
268
279
  return stream(model, context, {
@@ -31,7 +31,7 @@ export function transformMessages<TApi extends Api>(
31
31
  // Build a map of original tool call IDs to normalized IDs
32
32
  const toolCallIdMap = new Map<string, string>();
33
33
  // Normalize null/undefined content from untyped callers (custom tools, hand-built
34
- // histories, old session files) so downstream code can rely on the type contract.
34
+ // histories) so downstream code can rely on the type contract.
35
35
  const normalizedMessages = messages.map((msg) => (msg.content == null ? { ...msg, content: [] } : msg));
36
36
  validateInputModalities(normalizedMessages, model);
37
37
 
@@ -261,6 +261,8 @@ export interface UserMessage {
261
261
  role: "user";
262
262
  content: string | UserContent[];
263
263
  timestamp: number; // Unix timestamp in milliseconds
264
+ /** A note for this one request only; the cache boundary stays in front of it. */
265
+ transient?: true;
264
266
  }
265
267
 
266
268
  export interface AssistantMessage {
@@ -97,5 +97,19 @@ export function isRetryableAssistantError(message: AssistantMessage): boolean {
97
97
  if (message.stopReason !== "error" || !message.errorMessage) return false;
98
98
  const errorMessage = message.errorMessage;
99
99
  if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
100
+ if (isDeterministicClientError(errorMessage)) return false;
100
101
  return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);
101
102
  }
103
+
104
+ const RETRYABLE_CLIENT_STATUSES = new Set([408, 409, 429]);
105
+
106
+ /** The status leads the formatted error ("400: body", "Relay url (400): body"), sits in a JSON body ("code"/"status": 400) or follows "HTTP". */
107
+ const ERROR_STATUS_PATTERN = /^(\d{3}): |\((\d{3})\): |"(?:code|status)"\s*:\s*(\d{3})\b|\bHTTP\s*(\d{3})\b/i;
108
+
109
+ /** A rejected request fails the same way again: 4xx other than timeout, conflict and throttling, or Anthropic's invalid_request_error. */
110
+ function isDeterministicClientError(errorMessage: string): boolean {
111
+ if (/invalid_request_error/.test(errorMessage)) return true;
112
+ const match = ERROR_STATUS_PATTERN.exec(errorMessage);
113
+ const status = Number(match?.slice(1).find((group) => group !== undefined));
114
+ return status >= 400 && status < 500 && !RETRYABLE_CLIENT_STATUSES.has(status);
115
+ }
@@ -61,6 +61,21 @@ const eventPayloads = {
61
61
  "turn.interrupted": payloadOf<"turn.interrupted">({
62
62
  turnId: Type.String({ description: "ID des unterbrochenen Turns" }),
63
63
  }),
64
+ "model.input.presented": payloadOf<"model.input.presented">({
65
+ turnId: Type.String({ description: "Turn in which the model was given the input" }),
66
+ }),
67
+ "model.step.completed": payloadOf<"model.step.completed">({
68
+ turnId: Type.String({ description: "Turn the model step belongs to" }),
69
+ stopReason: Type.String(),
70
+ }),
71
+ "model.tool-result.presented": payloadOf<"model.tool-result.presented">({
72
+ turnId: Type.String(),
73
+ toolCallId: Type.String({ description: "ID of the tool call" }),
74
+ }),
75
+ "context.compacted": payloadOf<"context.compacted">({
76
+ turnId: Type.String({ description: "Turn in which the context was compacted" }),
77
+ tokensBefore: Type.Integer(),
78
+ }),
64
79
  "model.output.completed": payloadOf<"model.output.completed">({
65
80
  turnId: Type.String({ description: "Turn, zu dem die Ausgabe gehört" }),
66
81
  }),
@@ -0,0 +1,360 @@
1
+ import {
2
+ contextMessages,
3
+ latestCompaction,
4
+ type AgentMessage,
5
+ type ContextLogEntry,
6
+ } from "@ragents/agent";
7
+ import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage, UserContent, UserMessage } from "@ragents/ai";
8
+
9
+ import type { EventPayloads, JournalEvent, ModelInputContent, ModelToolResultPart } from "../domain/events.ts";
10
+ import type { JsonValue } from "../domain/json.ts";
11
+ import { pluginStateKey } from "../domain/model.ts";
12
+ import { derivedToolResultText } from "../domain/tool-result-text.ts";
13
+ import type { ModelContextRecord, TurnRequest } from "../drivers/types.ts";
14
+ import type { Orchestration } from "../runtime/orchestration.ts";
15
+ import type { ClaimedTurn } from "./turn.ts";
16
+
17
+ /** Reads the bytes behind a stored media hash as Base64. */
18
+ export type MediaSource = (hash: string) => string;
19
+
20
+ /** The model context of one actor, projected from the journal; `key` changes with every compaction. */
21
+ export type ModelContext = {
22
+ readonly entries: readonly ContextLogEntry[];
23
+ readonly messages: readonly AgentMessage[];
24
+ readonly key: string;
25
+ };
26
+
27
+ type ToolEvent = Extract<JournalEvent, { type: "tool.call.completed" | "tool.call.failed" }>;
28
+
29
+ const timeOf = (event: JournalEvent) => Date.parse(event.occurredAt);
30
+
31
+ const userContentOf = (content: ModelInputContent, media: MediaSource): string | UserContent[] =>
32
+ typeof content === "string"
33
+ ? content
34
+ : content.map((part): UserContent => {
35
+ if (part.type === "text") return part;
36
+ if (part.type === "file") return { type: "file", data: media(part.hash), mimeType: part.mimeType, filename: part.filename };
37
+ return { type: part.type, data: media(part.hash), mimeType: part.mimeType };
38
+ });
39
+
40
+ const toolResultContentOf = (content: readonly ModelToolResultPart[], media: MediaSource): (TextContent | ImageContent)[] =>
41
+ content.map((part) => part.type === "text" ? part : { type: "image", data: media(part.hash), mimeType: part.mimeType });
42
+
43
+ const assistantMessageOf = (
44
+ event: Extract<JournalEvent, { type: "model.step.completed" }>,
45
+ observed: readonly JournalEvent[],
46
+ ): AssistantMessage => {
47
+ const texts = observed.filter((entry) => entry.type === "model.output.completed").map((entry) => entry.payload.text as string);
48
+ const thoughts = observed.filter((entry) => entry.type === "model.reasoning.completed").map((entry) => entry.payload.text as string);
49
+ const next = (queue: string[], kind: string) => {
50
+ const value = queue.shift();
51
+
52
+ if (value === undefined)
53
+ throw new Error(`Model step ${event.eventId} misses the ${kind} of one of its blocks in its command.`);
54
+
55
+ return value;
56
+ };
57
+ const { turnId: _turnId, content, ...step } = event.payload;
58
+ const message: AssistantMessage = {
59
+ role: "assistant",
60
+ ...step,
61
+ content: content.map((block) => {
62
+ if (block.type === "text")
63
+ return block.text === undefined ? { ...block, text: next(texts, "text") } : { ...block, text: block.text };
64
+
65
+ if (block.type === "thinking")
66
+ return block.thinking === undefined ? { ...block, thinking: next(thoughts, "reasoning") } : { ...block, thinking: block.thinking };
67
+
68
+ return block;
69
+ }),
70
+ } as unknown as AssistantMessage;
71
+
72
+ if (texts.length > 0 || thoughts.length > 0)
73
+ throw new Error(`Model step ${event.eventId} has observation events that none of its blocks takes.`);
74
+
75
+ return message;
76
+ };
77
+
78
+ /** The context log of one actor from its own events, built event by event. */
79
+ class OwnContextLog {
80
+ readonly entries: ContextLogEntry[] = [];
81
+ readonly #actorId: string;
82
+ readonly #media: MediaSource;
83
+ readonly #toolResults = new Map<string, ToolEvent>();
84
+ readonly #observedByCommand = new Map<string, JournalEvent[]>();
85
+
86
+ constructor(actorId: string, media: MediaSource) {
87
+ this.#actorId = actorId;
88
+ this.#media = media;
89
+ }
90
+
91
+ /** Takes one event of the run; true when the context of the actor changed. */
92
+ apply(event: JournalEvent): boolean {
93
+ if (event.actorId !== this.#actorId)
94
+ return false;
95
+
96
+ switch (event.type) {
97
+ case "tool.call.completed":
98
+ case "tool.call.failed":
99
+ this.#toolResults.set(`${event.payload.turnId}\0${event.payload.toolCallId}`, event);
100
+ return false;
101
+
102
+ case "model.output.completed":
103
+ case "model.reasoning.completed":
104
+ this.#observedByCommand.set(event.commandId, [...(this.#observedByCommand.get(event.commandId) ?? []), event]);
105
+ return false;
106
+
107
+ case "model.input.presented": {
108
+ const message: UserMessage = { role: "user", content: userContentOf(event.payload.content, this.#media), timestamp: timeOf(event) };
109
+ this.entries.push({ kind: "message", id: event.eventId, message });
110
+ return true;
111
+ }
112
+
113
+ case "model.step.completed":
114
+ this.entries.push({ kind: "message", id: event.eventId, message: assistantMessageOf(event, this.#observedByCommand.get(event.commandId) ?? []) });
115
+ this.#observedByCommand.delete(event.commandId);
116
+ return true;
117
+
118
+ case "model.tool-result.presented": {
119
+ const key = `${event.payload.turnId}\0${event.payload.toolCallId}`;
120
+ this.entries.push({ kind: "message", id: event.eventId, message: toolResultMessageOf(event, this.#toolResults, this.#media) });
121
+ this.#toolResults.delete(key);
122
+ return true;
123
+ }
124
+
125
+ case "context.compacted":
126
+ this.entries.push({
127
+ kind: "compaction",
128
+ id: event.eventId,
129
+ summary: event.payload.summary,
130
+ firstKeptId: event.payload.firstKeptEventId,
131
+ tokensBefore: event.payload.tokensBefore,
132
+ timestamp: timeOf(event),
133
+ readFiles: [...event.payload.readFiles],
134
+ modifiedFiles: [...event.payload.modifiedFiles],
135
+ });
136
+ return true;
137
+
138
+ default:
139
+ return false;
140
+ }
141
+ }
142
+ }
143
+
144
+ const ownEntries = (events: readonly JournalEvent[], actorId: string, media: MediaSource): ContextLogEntry[] => {
145
+ const log = new OwnContextLog(actorId, media);
146
+
147
+ for (const event of events)
148
+ log.apply(event);
149
+
150
+ return log.entries;
151
+ };
152
+
153
+ const toolResultMessageOf = (
154
+ event: Extract<JournalEvent, { type: "model.tool-result.presented" }>,
155
+ toolResults: ReadonlyMap<string, ToolEvent>,
156
+ media: MediaSource,
157
+ ): ToolResultMessage => {
158
+ const payload: EventPayloads["model.tool-result.presented"] = event.payload;
159
+ let content: (TextContent | ImageContent)[];
160
+
161
+ if (payload.content) {
162
+ content = toolResultContentOf(payload.content, media);
163
+ } else {
164
+ const result = toolResults.get(`${payload.turnId}\0${payload.toolCallId}`);
165
+
166
+ if (!result)
167
+ throw new Error(`Tool result ${event.eventId} names call ${payload.toolCallId}, which has no recorded result.`);
168
+
169
+ content = [{ type: "text", text: derivedToolResultText(result) }];
170
+ }
171
+
172
+ return {
173
+ role: "toolResult",
174
+ toolCallId: payload.toolCallId,
175
+ toolName: payload.toolName,
176
+ content,
177
+ isError: payload.isError,
178
+ timestamp: timeOf(event),
179
+ };
180
+ };
181
+
182
+ /** Where a fork cuts its source: before the source's turn that was still running at the spawn, else at the spawn. */
183
+ const forkCut = (events: readonly JournalEvent[], source: string, spawnSequence: number): number => {
184
+ const running = new Map<string, number>();
185
+
186
+ for (const event of events) {
187
+ if (event.sequence >= spawnSequence)
188
+ break;
189
+
190
+ if (event.type === "turn.started" && event.actorId === source)
191
+ running.set(event.payload.turnId, event.sequence);
192
+ else if (event.type === "turn.finished" || event.type === "turn.interrupted")
193
+ running.delete(event.payload.turnId);
194
+ }
195
+
196
+ return Math.min(spawnSequence, ...running.values());
197
+ };
198
+
199
+ /** What a fork took over from its source: an unchanged copy of the context of the source's finished turns. */
200
+ const inheritedEntries = (events: readonly JournalEvent[], actorId: string, media: MediaSource): ContextLogEntry[] => {
201
+ const spawn = events.find((event): event is Extract<JournalEvent, { type: "agent.spawned" }> =>
202
+ event.type === "agent.spawned" && event.payload.agentId === actorId);
203
+ const source = spawn?.payload.forkOf;
204
+
205
+ if (!source)
206
+ return [];
207
+
208
+ const cut = forkCut(events, source, spawn.sequence);
209
+ const inherited = modelContextLog(events.filter((event) => event.sequence < cut), source, media);
210
+
211
+ if (inherited.length === 0)
212
+ throw new Error(`Fork source ${source} had no model context from a finished turn at the spawn.`);
213
+
214
+ return inherited;
215
+ };
216
+
217
+ /** The context log of an actor, including the part a fork took over from its source at its spawn. */
218
+ export const modelContextLog = (events: readonly JournalEvent[], actorId: string, media: MediaSource): ContextLogEntry[] =>
219
+ [...inheritedEntries(events, actorId, media), ...ownEntries(events, actorId, media)];
220
+
221
+ const contextOf = (entries: ContextLogEntry[], conversation: string): ModelContext => ({
222
+ entries,
223
+ messages: contextMessages(entries),
224
+ key: `${conversation}:${latestCompaction(entries)?.id ?? ""}`,
225
+ });
226
+
227
+ /** The model context of an actor as a pure projection of the journal; no rendering, only stored forms. */
228
+ export const modelContextOf = (events: readonly JournalEvent[], actorId: string, media: MediaSource): ModelContext =>
229
+ contextOf(modelContextLog(events, actorId, media), events.find((event) => event.type === "run.created")?.eventId ?? "");
230
+
231
+ const deepFreeze = <T>(value: T): T => {
232
+ if (value === null || typeof value !== "object" || Object.isFrozen(value))
233
+ return value;
234
+
235
+ for (const entry of Object.values(value))
236
+ deepFreeze(entry);
237
+
238
+ return Object.freeze(value);
239
+ };
240
+
241
+ /** The events of a run as the journal holds them; the sequence counts from 1 without gaps. */
242
+ export type ModelContextSource = {
243
+ eventsSince: (runId: string, sequence: number) => readonly JournalEvent[];
244
+ firstEventId: (runId: string) => string | undefined;
245
+ media: MediaSource;
246
+ };
247
+
248
+ type CachedContext = {
249
+ conversation: string;
250
+ sequence: number;
251
+ inherited: ContextLogEntry[];
252
+ own: OwnContextLog;
253
+ context: ModelContext | null;
254
+ };
255
+
256
+ /** The model contexts of actors in a turn, extended by new events only; always equal to `modelContextOf` over the journal. */
257
+ export class ModelContexts {
258
+ readonly #source: ModelContextSource;
259
+ readonly #runs = new Map<string, Map<string, CachedContext>>();
260
+
261
+ constructor(source: ModelContextSource) {
262
+ this.#source = source;
263
+ }
264
+
265
+ contextOf(runId: string, actorId: string): ModelContext {
266
+ const actors = this.#runs.get(runId) ?? new Map<string, CachedContext>();
267
+ const conversation = this.#source.firstEventId(runId) ?? "";
268
+ let cached = actors.get(actorId);
269
+
270
+ if (!cached || cached.conversation !== conversation) {
271
+ const events = this.#source.eventsSince(runId, 0);
272
+ cached = {
273
+ conversation,
274
+ sequence: 0,
275
+ inherited: deepFreeze(inheritedEntries(events, actorId, this.#source.media)),
276
+ own: new OwnContextLog(actorId, this.#source.media),
277
+ context: null,
278
+ };
279
+ this.#extend(cached, events);
280
+ } else {
281
+ this.#extend(cached, this.#source.eventsSince(runId, cached.sequence));
282
+ }
283
+
284
+ actors.set(actorId, cached);
285
+ this.#runs.set(runId, actors);
286
+ cached.context ??= deepFreeze(contextOf([...cached.inherited, ...cached.own.entries], conversation));
287
+
288
+ return cached.context;
289
+ }
290
+
291
+ forget(runId: string) {
292
+ this.#runs.delete(runId);
293
+ }
294
+
295
+ /** Drops what an actor holds once its turn ended; the next turn builds its context once from the journal. */
296
+ forgetActor(runId: string, actorId: string) {
297
+ const actors = this.#runs.get(runId);
298
+ actors?.delete(actorId);
299
+
300
+ if (actors?.size === 0)
301
+ this.#runs.delete(runId);
302
+ }
303
+
304
+ /** How many actor contexts are held; only actors in a running turn keep one. */
305
+ get size() {
306
+ return [...this.#runs.values()].reduce((sum, actors) => sum + actors.size, 0);
307
+ }
308
+
309
+ #extend(cached: CachedContext, events: readonly JournalEvent[]) {
310
+ for (const event of events) {
311
+ if (event.sequence !== cached.sequence + 1)
312
+ throw new Error(`The model context expected event ${cached.sequence + 1} and got ${event.sequence}.`);
313
+
314
+ cached.sequence = event.sequence;
315
+
316
+ if (cached.own.apply(event))
317
+ cached.context = null;
318
+ }
319
+ }
320
+ }
321
+
322
+ /** How a turn of an LLM actor reads and writes its model context in the journal; `next` numbers the commands of the turn. */
323
+ export const turnModelContext = (
324
+ runtime: Orchestration,
325
+ turn: ClaimedTurn,
326
+ next: () => number,
327
+ ): Pick<TurnRequest<"agent">, "modelContext" | "recordContext" | "hookState"> => {
328
+ const { runId, actorId, turnId } = turn;
329
+ const context = (kind: string) => ({ actorId, commandId: `scheduler:${turnId}:context:${kind}:${next()}`, turnId, correlationId: turnId });
330
+
331
+ return {
332
+ modelContext: () => runtime.modelContext(runId, actorId),
333
+ recordContext: (entry: ModelContextRecord) => {
334
+ if (entry.kind === "input")
335
+ runtime.presentModelInput(context(entry.kind), runId, actorId, { turnId, inputId: entry.inputId, content: entry.content });
336
+ else if (entry.kind === "step")
337
+ runtime.completeModelStep(context(entry.kind), runId, actorId, { turnId, step: entry.step });
338
+ else if (entry.kind === "tool-result")
339
+ runtime.presentToolResult(context(entry.kind), runId, actorId, {
340
+ turnId,
341
+ toolCallId: entry.toolCallId,
342
+ toolName: entry.toolName,
343
+ isError: entry.isError,
344
+ content: entry.content,
345
+ });
346
+ else
347
+ runtime.compactContext(context(entry.kind), runId, actorId, { turnId, ...entry.compaction });
348
+ },
349
+ hookState: {
350
+ kept: (hookId: string) => runtime.select(runId, (state) => state.pluginStates.get(pluginStateKey(hookId, { kind: "actor", actorId }))?.state),
351
+ keep: (hookId: string, value: JsonValue) => {
352
+ runtime.replacePluginState(
353
+ { actorId: runtime.select(runId, (state) => state.ownerId), commandId: `scheduler:${turnId}:hook:${hookId}:${next()}`, turnId },
354
+ runId,
355
+ { pluginId: hookId, scope: { kind: "actor", actorId }, state: value },
356
+ );
357
+ },
358
+ },
359
+ };
360
+ };
@@ -19,6 +19,8 @@ export type ToolContributor = {
19
19
  name: string;
20
20
  descriptors: readonly ToolDescriptor[];
21
21
  dynamic?: boolean;
22
+ /** Ob die Werkzeuge in einem Run gelten; ohne Angabe in jedem, sonst fehlen sie dort ganz. */
23
+ runCondition?: (runId: string) => boolean;
22
24
  tools: (context: PluginContext) => readonly RunFunction[] | Promise<readonly RunFunction[]>;
23
25
  };
24
26
 
@@ -74,6 +76,9 @@ export class ToolRegistry implements ToolProvider {
74
76
  const taken = new Set(agentTools.map((entry) => entry.name));
75
77
 
76
78
  for (const plugin of this.#plugins) {
79
+ if (plugin.runCondition && !plugin.runCondition(context.runId))
80
+ continue;
81
+
77
82
  const tools = await plugin.tools(context);
78
83
  this.#assertDescriptors(plugin, tools);
79
84