| 1 | import type { ThreadId, TurnItemId } from "@t3tools/contracts"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as DateTime from "effect/DateTime"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as SqlClient from "effect/sql/SqlClient"; |
| 8 | |
| 9 | /** Largest context one app may keep for the agent, in UTF-8 bytes. */ |
| 10 | export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024; |
| 11 | |
| 12 | export class McpAppModelContextError extends Schema.TaggedError<McpAppModelContextError>()( |
| 13 | "McpAppModelContextError", |
| 14 | { cause: Schema.optional(Schema.Defect()) }, |
| 15 | ) {} |
| 16 | |
| 17 | export interface McpAppModelContextEntry { |
| 18 | readonly itemId: string; |
| 19 | readonly server: string; |
| 20 | readonly tool: string; |
| 21 | readonly text: string; |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * What each MCP App told the agent through `ui/update-model-context`. The spec |
| 26 | * replaces an app's context on every update, so there is one entry per app (its |
| 27 | * tool call); the thread's next turn carries the entries to the provider. |
| 28 | */ |
| 29 | export class McpAppModelContext extends Context.Service< |
| 30 | McpAppModelContext, |
| 31 | { |
| 32 | /** Replaces the app's context; empty text removes it. */ |
| 33 | readonly set: (input: { |
| 34 | readonly threadId: ThreadId; |
| 35 | readonly itemId: TurnItemId; |
| 36 | readonly server: string; |
| 37 | readonly tool: string; |
| 38 | readonly text: string; |
| 39 | }) => Effect.Effect<void, McpAppModelContextError>; |
| 40 | readonly forThread: ( |
| 41 | threadId: ThreadId, |
| 42 | ) => Effect.Effect<ReadonlyArray<McpAppModelContextEntry>, McpAppModelContextError>; |
| 43 | } |
| 44 | >()("t3/mcpApps/McpAppModelContext") {} |
| 45 | |
| 46 | export const layer = Layer.effect( |
| 47 | McpAppModelContext, |
| 48 | Effect.gen(function* () { |
| 49 | const sql = yield* SqlClient.SqlClient; |
| 50 | const fail = (cause: unknown) => new McpAppModelContextError({ cause }); |
| 51 | |
| 52 | const set = Effect.fn("McpAppModelContext.set")(function* (input: { |
| 53 | readonly threadId: ThreadId; |
| 54 | readonly itemId: TurnItemId; |
| 55 | readonly server: string; |
| 56 | readonly tool: string; |
| 57 | readonly text: string; |
| 58 | }) { |
| 59 | if (input.text.trim() === "") { |
| 60 | yield* sql` |
| 61 | DELETE FROM mcp_app_model_context |
| 62 | WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId} |
| 63 | `.pipe(Effect.mapError(fail)); |
| 64 | return; |
| 65 | } |
| 66 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 67 | yield* sql` |
| 68 | INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at) |
| 69 | VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt}) |
| 70 | ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at |
| 71 | `.pipe(Effect.mapError(fail)); |
| 72 | }); |
| 73 | |
| 74 | const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { |
| 75 | // Only apps still in the thread's history count: an app whose item is |
| 76 | // gone, or whose run this thread rolled back, stops informing the agent. |
| 77 | // The item can live in a thread this one was forked from, so it is |
| 78 | // matched by id alone; that thread rolling the run back later leaves the |
| 79 | // fork's copy in place. |
| 80 | return yield* sql<McpAppModelContextEntry>` |
| 81 | SELECT context.item_id AS "itemId", context.server, context.tool, context.text |
| 82 | FROM mcp_app_model_context AS context |
| 83 | JOIN orchestration_v2_projection_turn_items AS item |
| 84 | ON item.turn_item_id = context.item_id |
| 85 | LEFT JOIN orchestration_v2_projection_runs AS run |
| 86 | ON run.run_id = item.run_id |
| 87 | WHERE context.thread_id = ${threadId} |
| 88 | AND ( |
| 89 | run.status IS NULL |
| 90 | OR run.status <> 'rolled_back' |
| 91 | OR run.thread_id <> context.thread_id |
| 92 | ) |
| 93 | ORDER BY context.updated_at, context.item_id |
| 94 | `.pipe(Effect.mapError(fail)); |
| 95 | }); |
| 96 | |
| 97 | return McpAppModelContext.of({ set, forThread }); |
| 98 | }), |
| 99 | ); |
| 100 | |
| 101 | /** No app context: for code paths and tests with no MCP Apps. */ |
| 102 | export const layerEmpty = Layer.succeed( |
| 103 | McpAppModelContext, |
| 104 | McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }), |
| 105 | ); |