effect-assistant 0.3.0 → 0.5.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.
Files changed (91) hide show
  1. package/dist/Assistant.d.ts +5 -11
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js.map +1 -1
  4. package/dist/AssistantEvent.d.ts +66 -7
  5. package/dist/AssistantEvent.d.ts.map +1 -1
  6. package/dist/AssistantEvent.js +74 -9
  7. package/dist/AssistantEvent.js.map +1 -1
  8. package/dist/AssistantLoop.d.ts +4 -2
  9. package/dist/AssistantLoop.d.ts.map +1 -1
  10. package/dist/AssistantLoop.js +4 -2
  11. package/dist/AssistantLoop.js.map +1 -1
  12. package/dist/Block.d.ts +63 -1
  13. package/dist/Block.d.ts.map +1 -1
  14. package/dist/Block.js +63 -2
  15. package/dist/Block.js.map +1 -1
  16. package/dist/BlockKit.d.ts +25 -3
  17. package/dist/BlockKit.d.ts.map +1 -1
  18. package/dist/BlockKit.js +14 -12
  19. package/dist/BlockKit.js.map +1 -1
  20. package/dist/Compaction.d.ts +1 -1
  21. package/dist/Compaction.d.ts.map +1 -1
  22. package/dist/Compaction.js +11 -4
  23. package/dist/Compaction.js.map +1 -1
  24. package/dist/Skill.d.ts +85 -43
  25. package/dist/Skill.d.ts.map +1 -1
  26. package/dist/Skill.js +93 -44
  27. package/dist/Skill.js.map +1 -1
  28. package/dist/Specialist.d.ts +6 -9
  29. package/dist/Specialist.d.ts.map +1 -1
  30. package/dist/Specialist.js.map +1 -1
  31. package/dist/Timeline.d.ts +44 -6
  32. package/dist/Timeline.d.ts.map +1 -1
  33. package/dist/Timeline.js +87 -16
  34. package/dist/Timeline.js.map +1 -1
  35. package/dist/ToolApproval.d.ts +146 -0
  36. package/dist/ToolApproval.d.ts.map +1 -0
  37. package/dist/ToolApproval.js +201 -0
  38. package/dist/ToolApproval.js.map +1 -0
  39. package/dist/ToolDiscovery.d.ts +6 -4
  40. package/dist/ToolDiscovery.d.ts.map +1 -1
  41. package/dist/ToolDiscovery.js +5 -4
  42. package/dist/ToolDiscovery.js.map +1 -1
  43. package/dist/ToolResult.d.ts +56 -0
  44. package/dist/ToolResult.d.ts.map +1 -0
  45. package/dist/ToolResult.js +49 -0
  46. package/dist/ToolResult.js.map +1 -0
  47. package/dist/index.d.ts +4 -2
  48. package/dist/index.js +6 -4
  49. package/dist/internal/chat.js +4 -1
  50. package/dist/internal/chat.js.map +1 -1
  51. package/dist/internal/history.js +28 -3
  52. package/dist/internal/history.js.map +1 -1
  53. package/dist/internal/loop.d.ts +10 -5
  54. package/dist/internal/loop.d.ts.map +1 -1
  55. package/dist/internal/loop.js +60 -21
  56. package/dist/internal/loop.js.map +1 -1
  57. package/dist/internal/specialists.js +9 -16
  58. package/dist/internal/specialists.js.map +1 -1
  59. package/dist/internal/toolkit.d.ts +11 -7
  60. package/dist/internal/toolkit.d.ts.map +1 -1
  61. package/dist/internal/toolkit.js +74 -21
  62. package/dist/internal/toolkit.js.map +1 -1
  63. package/dist/internal/tools.js +39 -6
  64. package/dist/internal/tools.js.map +1 -1
  65. package/dist/internal/turns.d.ts.map +1 -1
  66. package/dist/internal/turns.js +24 -16
  67. package/dist/internal/turns.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/Assistant.ts +7 -13
  70. package/src/AssistantEvent.ts +73 -8
  71. package/src/AssistantLoop.ts +4 -2
  72. package/src/Block.ts +82 -1
  73. package/src/BlockKit.ts +37 -4
  74. package/src/Compaction.ts +19 -2
  75. package/src/Skill.ts +134 -57
  76. package/src/Specialist.ts +8 -11
  77. package/src/Timeline.ts +120 -21
  78. package/src/ToolApproval.ts +249 -0
  79. package/src/ToolDiscovery.ts +8 -3
  80. package/src/ToolResult.ts +80 -0
  81. package/src/index.ts +10 -0
  82. package/src/internal/chat.ts +11 -1
  83. package/src/internal/history.ts +58 -3
  84. package/src/internal/loop.ts +138 -35
  85. package/src/internal/specialists.ts +7 -13
  86. package/src/internal/toolkit.ts +102 -35
  87. package/src/internal/tools.ts +81 -5
  88. package/src/internal/turns.ts +31 -10
  89. package/dist/internal/skills.js +0 -33
  90. package/dist/internal/skills.js.map +0 -1
  91. package/src/internal/skills.ts +0 -48
@@ -1,16 +1,16 @@
1
1
  import { ToolResult, TurnCompleted, TurnFailed, TurnStarted, isLibraryEvent } from "../AssistantEvent.js";
2
+ import { fromEntries } from "../ToolApproval.js";
2
3
  import { decodeEntries } from "./events.js";
3
4
  import { toJsonText } from "./schema.js";
4
- import { fromEntries, mayHaveRunOutput, notRunOutput, owesDelivery, owesReply, unansweredCalls, unhandledInputs, unreadAfter, userText } from "./history.js";
5
+ import { fromEntries as fromEntries$1, mayHaveRunOutput, notRunOutput, owesDelivery, owesReply, unansweredCalls, unhandledInputs, unreadAfter, userText } from "./history.js";
5
6
  import { agentInput, agentOutput, agentSpan, currentTraceId } from "./telemetry.js";
6
- import { duplicateNames, fromToolkitOrEmpty, merge, provide, runOnly } from "./toolkit.js";
7
+ import { duplicateNames, fromToolkitOrEmpty, merge, provide, runOnly, showing } from "./toolkit.js";
7
8
  import { makeMessaging, sendUserMessageName, waitToolName } from "./tools.js";
8
- import { TurnTools, run as run$1 } from "./loop.js";
9
- import { withCatalog } from "../Skill.js";
9
+ import { TurnApprovals, TurnTools, run as run$1 } from "./loop.js";
10
+ import { toolkit } from "./specialists.js";
11
+ import { toolNames } from "../BlockKit.js";
10
12
  import { protocol } from "../Instructions.js";
11
- import { available, toTools } from "./skills.js";
12
13
  import { mapText } from "./instructions.js";
13
- import { toolkit } from "./specialists.js";
14
14
  import "effect/Context";
15
15
  import * as Effect from "effect/Effect";
16
16
  import * as Predicate from "effect/Predicate";
@@ -18,6 +18,7 @@ import * as Schema from "effect/Schema";
18
18
  import * as Stream from "effect/Stream";
19
19
  import * as Exit from "effect/Exit";
20
20
  import * as Option from "effect/Option";
21
+ import * as Response from "effect/ai/Response";
21
22
  import * as Ref from "effect/Ref";
22
23
  import * as Cause from "effect/Cause";
23
24
  import { identity } from "effect/Function";
@@ -56,11 +57,17 @@ const failureMessage = (cause) => Option.match(Schema.decodeUnknownOption(Provid
56
57
  onNone: () => Cause.pretty(cause),
57
58
  onSome: ({ _tag, raw }) => `${_tag ?? "Error"}: ${Predicate.isString(raw) ? raw : toJsonText(raw)}`
58
59
  });
60
+ const approvedCalls = (entries) => [...fromEntries(entries).values()].flatMap(({ request, status, outcome }) => status === "approved" && outcome === void 0 ? [Response.makePart("tool-call", {
61
+ id: request.callId,
62
+ name: request.tool,
63
+ params: request.params,
64
+ providerExecuted: false
65
+ })] : []);
59
66
  const lastSeq = (entries, otherwise) => entries.at(-1)?.seq ?? otherwise;
60
67
  const closeOpenCalls = Effect.fnUntraced(function* (chat, startSeq) {
61
68
  const entries = (yield* chat.entries).filter(({ seq }) => seq > startSeq);
62
69
  const started = new Set(entries.flatMap(({ event }) => Predicate.isTagged(event, "ToolStarted") ? [event.callId] : []));
63
- const open = unansweredCalls(fromEntries(entries));
70
+ const open = unansweredCalls(fromEntries$1(entries));
64
71
  if (open.length === 0) return;
65
72
  yield* chat.append(open.map((call) => ToolResult.make({
66
73
  callId: call.id,
@@ -95,12 +102,13 @@ const failTurn = Effect.fnUntraced(function* (chat, cause) {
95
102
  const makeTurnApi = (registration, chat, startSeq, checkOpen, ranLoop) => {
96
103
  const { definition, builtins } = registration;
97
104
  const { app } = definition;
105
+ const shownTools = toolNames(definition.blocks);
98
106
  const running = (stream) => Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)));
99
107
  const history = Effect.fnUntraced(function* (view = identity) {
100
108
  yield* checkOpen;
101
109
  const all = yield* chat.entries;
102
110
  return {
103
- messages: fromEntries(view(decodeEntries(app, all))),
111
+ messages: fromEntries$1(view(decodeEntries(app, all))),
104
112
  throughSeq: lastSeq(all, startSeq)
105
113
  };
106
114
  });
@@ -120,12 +128,13 @@ const makeTurnApi = (registration, chat, startSeq, checkOpen, ranLoop) => {
120
128
  yield* checkOpen;
121
129
  const entries = yield* chat.entries;
122
130
  return {
123
- messages: [...fromEntries(options.view(decodeEntries(app, entries))), userText(nudge)],
131
+ messages: [...fromEntries$1(options.view(decodeEntries(app, entries))), userText(nudge)],
124
132
  throughSeq: lastSeq(entries, startSeq)
125
133
  };
126
134
  }),
127
- maxSteps: 2
128
- });
135
+ maxSteps: 2,
136
+ toolChoice: "required"
137
+ }).pipe(Stream.provideService(TurnApprovals, Option.none()));
129
138
  }));
130
139
  const replyIfOwed = (options) => running(replyPass({
131
140
  instructions: instructions(options.instructions),
@@ -134,11 +143,10 @@ const makeTurnApi = (registration, chat, startSeq, checkOpen, ranLoop) => {
134
143
  nudge: options.nudge
135
144
  }));
136
145
  const prepare = (options) => {
137
- const own = fromToolkitOrEmpty(options.toolkit);
138
- const skills = available(options.skills ?? [], [...builtins.definitions, ...own.definitions]);
146
+ const own = showing(fromToolkitOrEmpty(options.toolkit), shownTools);
139
147
  return {
140
- system: mapText(instructions(options.instructions), (text) => withCatalog(text, skills)),
141
- toolkit: merge(merge(builtins, toTools(skills)), own),
148
+ system: instructions(options.instructions),
149
+ toolkit: merge(builtins, own),
142
150
  view: options.history ?? identity
143
151
  };
144
152
  };
@@ -183,7 +191,7 @@ const run = Effect.fnUntraced(function* (options) {
183
191
  const ranLoop = yield* Ref.make(false);
184
192
  const turn = makeTurnApi(options.registration, chat, startSeq, options.checkOpen, ranLoop);
185
193
  const record = (event) => chat.append([event]);
186
- yield* options.handler(turn).pipe(Stream.runForEach(record), Effect.onExit((exit) => Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSeq)), Effect.provideService(TurnTools, Option.some(provide(options.registration.builtins, options.services))), Effect.provideContext(options.services), Effect.matchCauseEffect({
194
+ yield* options.handler(turn).pipe(Stream.runForEach(record), Effect.onExit((exit) => Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSeq)), Effect.provideService(TurnTools, Option.some(provide(options.registration.builtins, options.services))), Effect.provideService(TurnApprovals, Option.some({ approved: Effect.map(chat.entries, approvedCalls) })), Effect.provideContext(options.services), Effect.matchCauseEffect({
187
195
  onSuccess: Effect.fnUntraced(function* () {
188
196
  const all = yield* chat.entries;
189
197
  yield* finishTurn(chat, handledThrough(all, startSeq), yield* Ref.get(ranLoop));
@@ -1 +1 @@
1
- {"version":3,"file":"turns.js","names":["Toolkits.merge","Tools.makeMessaging","Specialists.toolkit","History.unansweredCalls","History.fromEntries","History.mayHaveRunOutput","History.notRunOutput","AssistantEvent.isLibraryEvent","History.owesReply","History.unreadAfter","AppEvents.decodeEntries","Instructions.protocol","History.owesDelivery","Loop.run","Toolkits.runOnly","Tools.sendUserMessageName","Tools.waitToolName","History.userText","Toolkits.fromToolkitOrEmpty","Skills.available","Skill.withCatalog","Skills.toTools","Toolkits.duplicateNames","Telemetry.agentInput","History.unhandledInputs","Telemetry.currentTraceId","Loop.TurnTools","Toolkits.provide","Telemetry.agentOutput","Telemetry.agentSpan"],"sources":["../../src/internal/turns.ts"],"sourcesContent":["import type * as LanguageModel from \"effect/ai/LanguageModel\"\nimport type * as Tool from \"effect/ai/Tool\"\nimport * as Cause from \"effect/Cause\"\nimport * as Context from \"effect/Context\"\nimport * as Effect from \"effect/Effect\"\nimport * as Exit from \"effect/Exit\"\nimport { identity } from \"effect/Function\"\nimport * as Option from \"effect/Option\"\nimport * as Predicate from \"effect/Predicate\"\nimport * as Ref from \"effect/Ref\"\nimport * as Schema from \"effect/Schema\"\nimport type * as Scope from \"effect/Scope\"\nimport * as Stream from \"effect/Stream\"\nimport type * as Assistant from \"../Assistant.ts\"\nimport type { ChatId } from \"../ChatId.ts\"\nimport * as AssistantEvent from \"../AssistantEvent.ts\"\nimport type * as AssistantLoop from \"../AssistantLoop.ts\"\nimport * as Loop from \"./loop.ts\"\nimport type * as BlockKit from \"../BlockKit.ts\"\nimport type { CurrentChat } from \"../CurrentChat.ts\"\nimport * as Instructions from \"../Instructions.ts\"\nimport * as Skill from \"../Skill.ts\"\nimport * as Skills from \"./skills.ts\"\nimport type * as Specialist from \"../Specialist.ts\"\nimport * as AppEvents from \"./events.ts\"\nimport * as History from \"./history.ts\"\nimport { mapText } from \"./instructions.ts\"\nimport { toJsonText } from \"./schema.ts\"\nimport * as Specialists from \"./specialists.ts\"\nimport * as Telemetry from \"./telemetry.ts\"\nimport * as Toolkits from \"./toolkit.ts\"\nimport type { Writer } from \"./writer.ts\"\nimport * as Tools from \"./tools.ts\"\n\n/** @internal */\nexport const silentTurnMessage = \"I finished without sending a reply.\"\n\n/** @internal */\nexport const defaultReplyNudge =\n \"You haven't replied to the user's latest message yet. Reply now with send_user_message.\"\n\n/** @internal */\nexport const deliveryNudge =\n \"You told the user you're on it, but stopped without giving them the result. Send it now with send_user_message; if part of it is still running, say what and that you'll report back. If they already have everything, call wait.\"\n\n/** @internal */\nexport interface Definition {\n readonly name: string\n readonly blocks: BlockKit.BlockKit | undefined\n readonly specialists: ReadonlyArray<Specialist.Any>\n readonly app: AppEvents.AppEvents\n}\n\n// Once its build ran the layer's services are provided, so its streams need only what a turn provides.\n/** @internal */\nexport type TurnHandler = (\n turn: Assistant.Turn<any>\n) => Stream.Stream<AssistantLoop.Event, unknown, Assistant.TurnServices | LanguageModel.LanguageModel>\n\n// The chat `CurrentChat` gives, with its entries typed as the assistant's decode returns them: the assistant's typed\n// chat is this one, so it needs no check of its own.\n/** @internal */\nexport interface TypedChat {\n readonly chatId: ChatId\n readonly entries: Effect.Effect<AppEvents.DecodedEntries>\n readonly append: CurrentChat[\"Service\"][\"append\"]\n}\n\n/** @internal */\nexport interface Registration {\n readonly definition: Definition\n readonly model: Context.Context<LanguageModel.LanguageModel>\n readonly open: (chat: TypedChat) => Effect.Effect<TurnHandler, never, Scope.Scope | CurrentChat>\n readonly specialists: ReadonlyArray<{\n readonly specialist: Specialist.Any\n readonly built: Specialist.Built\n }>\n readonly builtins: Toolkits.Tools<Assistant.TurnServices>\n}\n\n/** @internal */\nexport const register = Effect.fnUntraced(function* (\n definition: Definition,\n options: { readonly open: Registration[\"open\"] }\n) {\n const model = yield* Effect.context<LanguageModel.LanguageModel>()\n\n const specialists = yield* Effect.forEach(\n definition.specialists,\n Effect.fnUntraced(function* (specialist) {\n const built = yield* Effect.serviceOption(specialist.Handler)\n\n if (Option.isNone(built)) {\n return yield* Effect.die(\n `No handler for the ${specialist.name} specialist: provide ${specialist.name}.toLayer.`\n )\n }\n\n return { specialist, built: built.value }\n })\n )\n\n const builtins = Toolkits.merge(\n yield* Tools.makeMessaging(definition.blocks),\n yield* Specialists.toolkit(definition.specialists)\n )\n\n const registration: Registration = {\n definition,\n model,\n open: options.open,\n specialists,\n builtins\n }\n\n return registration\n})\n\nconst ProviderError = Schema.Struct({ _tag: Schema.optional(Schema.String), raw: Schema.Unknown })\n\n// Provider errors keep their response body.\nconst failureMessage = (cause: Cause.Cause<unknown>): string =>\n Option.match(Schema.decodeUnknownOption(ProviderError)(Cause.squash(cause)), {\n onNone: () => Cause.pretty(cause),\n onSome: ({ _tag, raw }) => `${_tag ?? \"Error\"}: ${Predicate.isString(raw) ? raw : toJsonText(raw)}`\n })\n\nconst lastSeq = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) => entries.at(-1)?.seq ?? otherwise\n\n// The handler's stream can end between a step's output and its tools, or while they run. An interrupted turn (a\n// shutdown) leaves its calls open instead: the next turn runs them again.\nconst closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: number) {\n const entries = (yield* chat.entries).filter(({ seq }) => seq > startSeq)\n\n const started = new Set(\n entries.flatMap(({ event }) => (Predicate.isTagged(event, \"ToolStarted\") ? [event.callId] : []))\n )\n\n const open = History.unansweredCalls(History.fromEntries(entries))\n\n if (open.length === 0) return\n\n yield* chat.append(\n open.map((call) =>\n AssistantEvent.ToolResult.make({\n callId: call.id,\n tool: call.name,\n outcome: \"failure\",\n ...(started.has(call.id)\n ? { result: History.mayHaveRunOutput, reason: \"MayHaveRun\" }\n : { result: History.notRunOutput, reason: \"NotRun\" })\n })\n )\n )\n})\n\n// The inputs before the turn's `TurnStarted` (at `startSeq`) woke it; past that, what its model outputs record\n// reading.\nconst handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, startSeq: number) =>\n entries.reduce(\n (through, { seq, event }) =>\n seq > startSeq &&\n AssistantEvent.isLibraryEvent(event) &&\n Predicate.isTagged(event, \"ModelOutput\") &&\n event.seenThroughSeq !== undefined\n ? Math.max(through, event.seenThroughSeq)\n : through,\n startSeq\n )\n\n// A turn that ran the loop fails visibly when the user's latest input got no reply; an input that arrived after what\n// its model read gets the next turn, which replies. A handler that ended without the loop decided that itself.\nconst finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranLoop: boolean) {\n const entries = yield* chat.entries\n\n const end =\n ranLoop && History.owesReply(entries, read) && !History.unreadAfter(entries, read)\n ? AssistantEvent.TurnFailed.make({ throughSeq: read, message: silentTurnMessage })\n : AssistantEvent.TurnCompleted.make({ throughSeq: read })\n\n yield* chat.append([end])\n})\n\n// Everything stored so far counts as read, so a failing input isn't retried forever.\nconst failTurn = Effect.fnUntraced(function* (chat: Writer, cause: Cause.Cause<unknown>) {\n yield* Effect.logError(\"assistant turn failed\", cause)\n\n const last = lastSeq(yield* chat.entries, 0)\n\n yield* chat.append([AssistantEvent.TurnFailed.make({ throughSeq: last, message: failureMessage(cause) })])\n})\n\nconst makeTurnApi = (\n registration: Registration,\n chat: Writer,\n startSeq: number,\n checkOpen: Effect.Effect<void>,\n ranLoop: Ref.Ref<boolean>\n): Assistant.Turn<any> => {\n const { definition, builtins } = registration\n const { app } = definition\n\n const running = <A, E, R>(stream: Stream.Stream<A, E, R>) =>\n Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)))\n\n // With how far into the chat it goes: the step records it on the model's output.\n // Read before every model call: a chat the app deleted meanwhile stops here.\n const history = Effect.fnUntraced(function* (view: Assistant.HistoryView<any> = identity) {\n yield* checkOpen\n\n const all = yield* chat.entries\n\n const read: AssistantLoop.ChatHistory = {\n messages: History.fromEntries(view(AppEvents.decodeEntries(app, all))),\n throughSeq: lastSeq(all, startSeq)\n }\n\n return read\n })\n\n const instructions = (given: string | Instructions.Resolved) =>\n mapText(given, (text) => `${Instructions.protocol}\\n\\n${text}`)\n\n // The loop's own prompt and tools, so the prompt stays cached, with the nudge at the end.\n const replyPass = <R>(options: {\n readonly instructions: Instructions.Resolved\n readonly toolkit: Toolkits.Tools<R>\n readonly view: Assistant.HistoryView<any>\n readonly nudge: string | undefined\n }) =>\n Stream.unwrap(\n Effect.gen(function* () {\n const all = yield* chat.entries\n const through = handledThrough(all, startSeq)\n\n // Something arrived after what the loop's model read: the next turn reads it with the full loop, and replies.\n if (History.unreadAfter(all, through)) return Stream.empty\n\n const owed = History.owesReply(all, through)\n const undelivered = !owed && History.owesDelivery(all, through)\n\n if (!owed && !undelivered) return Stream.empty\n\n const nudge = undelivered ? deliveryNudge : (options.nudge ?? defaultReplyNudge)\n\n return Loop.run({\n instructions: options.instructions,\n // A reply that's owed can only be sent; a result it may have given may also be for nobody.\n toolkit: Toolkits.runOnly(\n options.toolkit,\n owed ? [Tools.sendUserMessageName] : [Tools.sendUserMessageName, Tools.waitToolName],\n \"Not now: reply to the user with send_user_message.\"\n ),\n history: Effect.gen(function* () {\n yield* checkOpen\n\n const entries = yield* chat.entries\n\n return {\n messages: [\n ...History.fromEntries(options.view(AppEvents.decodeEntries(app, entries))),\n History.userText(nudge)\n ],\n throughSeq: lastSeq(entries, startSeq)\n }\n }),\n // A second step, for a reply after a call it was refused.\n maxSteps: 2\n })\n })\n )\n\n const replyIfOwed = (options: Assistant.ReplyOptions) =>\n running(\n replyPass({\n instructions: instructions(options.instructions),\n toolkit: builtins,\n view: identity,\n nudge: options.nudge\n })\n )\n\n const prepare = <T extends Record<string, Tool.Any>, RS>(options: Assistant.LoopOptions<T, RS, any>) => {\n const own = Toolkits.fromToolkitOrEmpty(options.toolkit)\n const skills = Skills.available(options.skills ?? [], [...builtins.definitions, ...own.definitions])\n\n return {\n system: mapText(instructions(options.instructions), (text) => Skill.withCatalog(text, skills)),\n toolkit: Toolkits.merge(Toolkits.merge(builtins, Skills.toTools(skills)), own),\n view: options.history ?? identity\n }\n }\n\n return {\n chatId: chat.chatId,\n history,\n instructions,\n replyIfOwed,\n loop: (options) => {\n const { system, toolkit, view } = prepare(options)\n const twice = Toolkits.duplicateNames(toolkit)\n\n const loop =\n twice.length > 0\n ? Stream.die(`Tool names used twice, like one named after a built-in: ${twice.join(\", \")}`)\n : Loop.run({\n instructions: system,\n toolkit,\n history: history(view),\n maxSteps: options.maxSteps ?? 16\n })\n\n return running(Stream.concat(loop, replyPass({ instructions: system, toolkit, view, nudge: options.replyNudge })))\n }\n }\n}\n\n// Every event the handler's loop emits is recorded before the loop is pulled again.\n/** @internal */\nexport const run = Effect.fnUntraced(\n function* (options: {\n readonly registration: Registration\n readonly handler: TurnHandler\n readonly chat: Writer\n readonly services: Context.Context<Assistant.TurnServices | LanguageModel.LanguageModel>\n // A chat the app deleted is deleted here, and the turn stops.\n readonly checkOpen: Effect.Effect<void>\n }) {\n const { chat } = options\n const before = yield* chat.entries\n\n yield* Telemetry.agentInput(History.unhandledInputs(before))\n\n const assistant = options.registration.definition.name\n const traceId = yield* Telemetry.currentTraceId\n\n const started = yield* chat.append([\n AssistantEvent.TurnStarted.make(\n Option.match(traceId, {\n onNone: () => ({ assistant }),\n onSome: (id) => ({ assistant, traceId: id })\n })\n )\n ])\n\n const startSeq = lastSeq(started, 0)\n const ranLoop = yield* Ref.make(false)\n const turn = makeTurnApi(options.registration, chat, startSeq, options.checkOpen, ranLoop)\n\n const record = (event: AssistantLoop.Event) => chat.append([event])\n\n yield* options.handler(turn).pipe(\n Stream.runForEach(record),\n Effect.onExit((exit) => (Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSeq))),\n // A loop the app writes gets the assistant's own tools too, with this turn's services.\n Effect.provideService(\n Loop.TurnTools,\n Option.some(Toolkits.provide(options.registration.builtins, options.services))\n ),\n Effect.provideContext(options.services),\n Effect.matchCauseEffect({\n onSuccess: Effect.fnUntraced(function* () {\n const all = yield* chat.entries\n\n yield* finishTurn(chat, handledThrough(all, startSeq), yield* Ref.get(ranLoop))\n }),\n onFailure: (cause) => failTurn(chat, cause)\n })\n )\n\n // The trace's output is what the user saw this turn, and a failure if any.\n const shown = (yield* chat.entries).flatMap(({ event, seq }) =>\n seq > startSeq && (Predicate.isTagged(event, \"AssistantMessage\") || Predicate.isTagged(event, \"TurnFailed\"))\n ? [event]\n : []\n )\n\n yield* Telemetry.agentOutput(shown)\n },\n (effect, options) =>\n effect.pipe(\n Telemetry.agentSpan(options.registration.definition.name, {\n chatId: options.chat.chatId,\n root: true\n })\n )\n)\n"],"mappings":"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"}
1
+ {"version":3,"file":"turns.js","names":["Toolkits.merge","Tools.makeMessaging","Specialists.toolkit","ToolApproval.fromEntries","History.unansweredCalls","History.fromEntries","History.mayHaveRunOutput","History.notRunOutput","AssistantEvent.isLibraryEvent","History.owesReply","History.unreadAfter","BlockKit.toolNames","AppEvents.decodeEntries","Instructions.protocol","History.owesDelivery","Loop.run","Toolkits.runOnly","Tools.sendUserMessageName","Tools.waitToolName","History.userText","Loop.TurnApprovals","Toolkits.showing","Toolkits.fromToolkitOrEmpty","Toolkits.duplicateNames","Telemetry.agentInput","History.unhandledInputs","Telemetry.currentTraceId","Loop.TurnTools","Toolkits.provide","Telemetry.agentOutput","Telemetry.agentSpan"],"sources":["../../src/internal/turns.ts"],"sourcesContent":["import type * as LanguageModel from \"effect/ai/LanguageModel\"\nimport * as Response from \"effect/ai/Response\"\nimport type * as Tool from \"effect/ai/Tool\"\nimport * as Cause from \"effect/Cause\"\nimport * as Context from \"effect/Context\"\nimport * as Effect from \"effect/Effect\"\nimport * as Exit from \"effect/Exit\"\nimport { identity } from \"effect/Function\"\nimport * as Option from \"effect/Option\"\nimport * as Predicate from \"effect/Predicate\"\nimport * as Ref from \"effect/Ref\"\nimport * as Schema from \"effect/Schema\"\nimport type * as Scope from \"effect/Scope\"\nimport * as Stream from \"effect/Stream\"\nimport type * as Assistant from \"../Assistant.ts\"\nimport type { ChatId } from \"../ChatId.ts\"\nimport * as AssistantEvent from \"../AssistantEvent.ts\"\nimport type * as AssistantLoop from \"../AssistantLoop.ts\"\nimport * as Loop from \"./loop.ts\"\nimport * as BlockKit from \"../BlockKit.ts\"\nimport type { CurrentChat } from \"../CurrentChat.ts\"\nimport * as Instructions from \"../Instructions.ts\"\nimport * as ToolApproval from \"../ToolApproval.ts\"\nimport type * as Specialist from \"../Specialist.ts\"\nimport * as AppEvents from \"./events.ts\"\nimport * as History from \"./history.ts\"\nimport { mapText } from \"./instructions.ts\"\nimport { toJsonText } from \"./schema.ts\"\nimport * as Specialists from \"./specialists.ts\"\nimport * as Telemetry from \"./telemetry.ts\"\nimport * as Toolkits from \"./toolkit.ts\"\nimport type { Writer } from \"./writer.ts\"\nimport * as Tools from \"./tools.ts\"\n\n/** @internal */\nexport const silentTurnMessage = \"I finished without sending a reply.\"\n\n/** @internal */\nexport const defaultReplyNudge =\n \"You haven't replied to the user's latest message yet. Reply now with send_user_message.\"\n\n/** @internal */\nexport const deliveryNudge =\n \"You told the user you're on it, but stopped without giving them the result. Send it now with send_user_message; if part of it is still running, say what and that you'll report back. If they already have everything, call wait.\"\n\n/** @internal */\nexport interface Definition {\n readonly name: string\n readonly blocks: BlockKit.BlockKit | undefined\n readonly specialists: ReadonlyArray<Specialist.Any>\n readonly app: AppEvents.AppEvents\n}\n\n// Once its build ran the layer's services are provided, so its streams need only what a turn provides.\n/** @internal */\nexport type TurnHandler = (\n turn: Assistant.Turn<any>\n) => Stream.Stream<AssistantLoop.Event, unknown, Assistant.TurnServices | LanguageModel.LanguageModel>\n\n// The chat `CurrentChat` gives, with its entries typed as the assistant's decode returns them: the assistant's typed\n// chat is this one, so it needs no check of its own.\n/** @internal */\nexport interface TypedChat {\n readonly chatId: ChatId\n readonly entries: Effect.Effect<AppEvents.DecodedEntries>\n readonly append: CurrentChat[\"Service\"][\"append\"]\n}\n\n/** @internal */\nexport interface Registration {\n readonly definition: Definition\n readonly model: Context.Context<LanguageModel.LanguageModel>\n readonly open: (chat: TypedChat) => Effect.Effect<TurnHandler, never, Scope.Scope | CurrentChat>\n readonly specialists: ReadonlyArray<{\n readonly specialist: Specialist.Any\n readonly built: Specialist.Built\n }>\n readonly builtins: Toolkits.Tools<Assistant.TurnServices>\n}\n\n/** @internal */\nexport const register = Effect.fnUntraced(function* (\n definition: Definition,\n options: { readonly open: Registration[\"open\"] }\n) {\n const model = yield* Effect.context<LanguageModel.LanguageModel>()\n\n const specialists = yield* Effect.forEach(\n definition.specialists,\n Effect.fnUntraced(function* (specialist) {\n const built = yield* Effect.serviceOption(specialist.Handler)\n\n if (Option.isNone(built)) {\n return yield* Effect.die(\n `No handler for the ${specialist.name} specialist: provide ${specialist.name}.toLayer.`\n )\n }\n\n return { specialist, built: built.value }\n })\n )\n\n const builtins = Toolkits.merge(\n yield* Tools.makeMessaging(definition.blocks),\n yield* Specialists.toolkit(definition.specialists)\n )\n\n const registration: Registration = {\n definition,\n model,\n open: options.open,\n specialists,\n builtins\n }\n\n return registration\n})\n\nconst ProviderError = Schema.Struct({ _tag: Schema.optional(Schema.String), raw: Schema.Unknown })\n\n// Provider errors keep their response body.\nconst failureMessage = (cause: Cause.Cause<unknown>): string =>\n Option.match(Schema.decodeUnknownOption(ProviderError)(Cause.squash(cause)), {\n onNone: () => Cause.pretty(cause),\n onSome: ({ _tag, raw }) => `${_tag ?? \"Error\"}: ${Predicate.isString(raw) ? raw : toJsonText(raw)}`\n })\n\n// Approved, and not run yet: also one a crash cut off while it ran, which runs again with the same id.\nconst approvedCalls = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlyArray<AssistantEvent.ToolCallPart> =>\n [...ToolApproval.fromEntries(entries).values()].flatMap(({ request, status, outcome }) =>\n status === \"approved\" && outcome === undefined\n ? [\n Response.makePart(\"tool-call\", {\n id: request.callId,\n name: request.tool,\n params: request.params,\n providerExecuted: false\n })\n ]\n : []\n )\n\nconst lastSeq = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) => entries.at(-1)?.seq ?? otherwise\n\n// The handler's stream can end between a step's output and its tools, or while they run. An interrupted turn (a\n// shutdown) leaves its calls open instead: the next turn runs them again.\nconst closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: number) {\n const entries = (yield* chat.entries).filter(({ seq }) => seq > startSeq)\n\n const started = new Set(\n entries.flatMap(({ event }) => (Predicate.isTagged(event, \"ToolStarted\") ? [event.callId] : []))\n )\n\n const open = History.unansweredCalls(History.fromEntries(entries))\n\n if (open.length === 0) return\n\n yield* chat.append(\n open.map((call) =>\n AssistantEvent.ToolResult.make({\n callId: call.id,\n tool: call.name,\n outcome: \"failure\",\n ...(started.has(call.id)\n ? { result: History.mayHaveRunOutput, reason: \"MayHaveRun\" }\n : { result: History.notRunOutput, reason: \"NotRun\" })\n })\n )\n )\n})\n\n// The inputs before the turn's `TurnStarted` (at `startSeq`) woke it; past that, what its model outputs record\n// reading.\nconst handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, startSeq: number) =>\n entries.reduce(\n (through, { seq, event }) =>\n seq > startSeq &&\n AssistantEvent.isLibraryEvent(event) &&\n Predicate.isTagged(event, \"ModelOutput\") &&\n event.seenThroughSeq !== undefined\n ? Math.max(through, event.seenThroughSeq)\n : through,\n startSeq\n )\n\n// A turn that ran the loop fails visibly when the user's latest input got no reply; an input that arrived after what\n// its model read gets the next turn, which replies. A handler that ended without the loop decided that itself.\nconst finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranLoop: boolean) {\n const entries = yield* chat.entries\n\n const end =\n ranLoop && History.owesReply(entries, read) && !History.unreadAfter(entries, read)\n ? AssistantEvent.TurnFailed.make({ throughSeq: read, message: silentTurnMessage })\n : AssistantEvent.TurnCompleted.make({ throughSeq: read })\n\n yield* chat.append([end])\n})\n\n// Everything stored so far counts as read, so a failing input isn't retried forever.\nconst failTurn = Effect.fnUntraced(function* (chat: Writer, cause: Cause.Cause<unknown>) {\n yield* Effect.logError(\"assistant turn failed\", cause)\n\n const last = lastSeq(yield* chat.entries, 0)\n\n yield* chat.append([AssistantEvent.TurnFailed.make({ throughSeq: last, message: failureMessage(cause) })])\n})\n\nconst makeTurnApi = (\n registration: Registration,\n chat: Writer,\n startSeq: number,\n checkOpen: Effect.Effect<void>,\n ranLoop: Ref.Ref<boolean>\n): Assistant.Turn<any> => {\n const { definition, builtins } = registration\n const { app } = definition\n const shownTools = BlockKit.toolNames(definition.blocks)\n\n const running = <A, E, R>(stream: Stream.Stream<A, E, R>) =>\n Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)))\n\n // With how far into the chat it goes: the step records it on the model's output.\n // Read before every model call: a chat the app deleted meanwhile stops here.\n const history = Effect.fnUntraced(function* (view: Assistant.HistoryView<any> = identity) {\n yield* checkOpen\n\n const all = yield* chat.entries\n\n const read: AssistantLoop.ChatHistory = {\n messages: History.fromEntries(view(AppEvents.decodeEntries(app, all))),\n throughSeq: lastSeq(all, startSeq)\n }\n\n return read\n })\n\n const instructions = (given: string | Instructions.Resolved) =>\n mapText(given, (text) => `${Instructions.protocol}\\n\\n${text}`)\n\n // The loop's own prompt and tools, so the prompt stays cached, with the nudge at the end.\n const replyPass = <R>(options: {\n readonly instructions: Instructions.Resolved\n readonly toolkit: Toolkits.Tools<R>\n readonly view: Assistant.HistoryView<any>\n readonly nudge: string | undefined\n }) =>\n Stream.unwrap(\n Effect.gen(function* () {\n const all = yield* chat.entries\n const through = handledThrough(all, startSeq)\n\n // Something arrived after what the loop's model read: the next turn reads it with the full loop, and replies.\n if (History.unreadAfter(all, through)) return Stream.empty\n\n const owed = History.owesReply(all, through)\n const undelivered = !owed && History.owesDelivery(all, through)\n\n if (!owed && !undelivered) return Stream.empty\n\n const nudge = undelivered ? deliveryNudge : (options.nudge ?? defaultReplyNudge)\n\n return Loop.run({\n instructions: options.instructions,\n // A reply that's owed can only be sent; a result it may have given may also be for nobody.\n toolkit: Toolkits.runOnly(\n options.toolkit,\n owed ? [Tools.sendUserMessageName] : [Tools.sendUserMessageName, Tools.waitToolName],\n \"Not now: reply to the user with send_user_message.\"\n ),\n history: Effect.gen(function* () {\n yield* checkOpen\n\n const entries = yield* chat.entries\n\n return {\n messages: [\n ...History.fromEntries(options.view(AppEvents.decodeEntries(app, entries))),\n History.userText(nudge)\n ],\n throughSeq: lastSeq(entries, startSeq)\n }\n }),\n // A second step, for a reply after a call it was refused.\n maxSteps: 2,\n // The pass runs because the model wrote text of its own instead of replying, which reaches nobody: it has to\n // call a tool now. Changing the choice costs a read of the history at full price, on these rare turns only.\n toolChoice: \"required\"\n // The loop ran the approved calls; a pass that can only reply runs none.\n }).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))\n })\n )\n\n const replyIfOwed = (options: Assistant.ReplyOptions) =>\n running(\n replyPass({\n instructions: instructions(options.instructions),\n toolkit: builtins,\n view: identity,\n nudge: options.nudge\n })\n )\n\n const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.LoopOptions<T, any>) => {\n const own = Toolkits.showing(Toolkits.fromToolkitOrEmpty(options.toolkit), shownTools)\n\n return {\n system: instructions(options.instructions),\n toolkit: Toolkits.merge(builtins, own),\n view: options.history ?? identity\n }\n }\n\n return {\n chatId: chat.chatId,\n history,\n instructions,\n replyIfOwed,\n loop: (options) => {\n const { system, toolkit, view } = prepare(options)\n const twice = Toolkits.duplicateNames(toolkit)\n\n const loop =\n twice.length > 0\n ? Stream.die(`Tool names used twice, like one named after a built-in: ${twice.join(\", \")}`)\n : Loop.run({\n instructions: system,\n toolkit,\n history: history(view),\n maxSteps: options.maxSteps ?? 16\n })\n\n return running(Stream.concat(loop, replyPass({ instructions: system, toolkit, view, nudge: options.replyNudge })))\n }\n }\n}\n\n// Every event the handler's loop emits is recorded before the loop is pulled again.\n/** @internal */\nexport const run = Effect.fnUntraced(\n function* (options: {\n readonly registration: Registration\n readonly handler: TurnHandler\n readonly chat: Writer\n readonly services: Context.Context<Assistant.TurnServices | LanguageModel.LanguageModel>\n // A chat the app deleted is deleted here, and the turn stops.\n readonly checkOpen: Effect.Effect<void>\n }) {\n const { chat } = options\n const before = yield* chat.entries\n\n yield* Telemetry.agentInput(History.unhandledInputs(before))\n\n const assistant = options.registration.definition.name\n const traceId = yield* Telemetry.currentTraceId\n\n const started = yield* chat.append([\n AssistantEvent.TurnStarted.make(\n Option.match(traceId, {\n onNone: () => ({ assistant }),\n onSome: (id) => ({ assistant, traceId: id })\n })\n )\n ])\n\n const startSeq = lastSeq(started, 0)\n const ranLoop = yield* Ref.make(false)\n const turn = makeTurnApi(options.registration, chat, startSeq, options.checkOpen, ranLoop)\n\n const record = (event: AssistantLoop.Event) => chat.append([event])\n\n yield* options.handler(turn).pipe(\n Stream.runForEach(record),\n Effect.onExit((exit) => (Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSeq))),\n // A loop the app writes gets the assistant's own tools too, with this turn's services.\n Effect.provideService(\n Loop.TurnTools,\n Option.some(Toolkits.provide(options.registration.builtins, options.services))\n ),\n // A turn's loop waits for the user's approval, and runs the calls they approved.\n Effect.provideService(Loop.TurnApprovals, Option.some({ approved: Effect.map(chat.entries, approvedCalls) })),\n Effect.provideContext(options.services),\n Effect.matchCauseEffect({\n onSuccess: Effect.fnUntraced(function* () {\n const all = yield* chat.entries\n\n yield* finishTurn(chat, handledThrough(all, startSeq), yield* Ref.get(ranLoop))\n }),\n onFailure: (cause) => failTurn(chat, cause)\n })\n )\n\n // The trace's output is what the user saw this turn, and a failure if any.\n const shown = (yield* chat.entries).flatMap(({ event, seq }) =>\n seq > startSeq && (Predicate.isTagged(event, \"AssistantMessage\") || Predicate.isTagged(event, \"TurnFailed\"))\n ? [event]\n : []\n )\n\n yield* Telemetry.agentOutput(shown)\n },\n (effect, options) =>\n effect.pipe(\n Telemetry.agentSpan(options.registration.definition.name, {\n chatId: options.chat.chatId,\n root: true\n })\n )\n)\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-assistant",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A colleague assistant on Effect: an append-only event log per chat, an interruptible agent loop, specialists, skills, and UI blocks",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/Assistant.ts CHANGED
@@ -34,7 +34,6 @@ import type { CurrentChat } from "./CurrentChat.ts"
34
34
  import type * as Instructions from "./Instructions.ts"
35
35
  import * as AppEvents from "./internal/events.ts"
36
36
  import * as Turns from "./internal/turns.ts"
37
- import type { Skill } from "./Skill.ts"
38
37
  import type * as Specialist from "./Specialist.ts"
39
38
 
40
39
  const TypeId = "~effect-assistant/Assistant" as const
@@ -54,21 +53,16 @@ export type TurnServices = CurrentChat | Specialist.Runner
54
53
  * @category models
55
54
  * @since 0.1.0
56
55
  */
57
- export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never, never>, RS = never, Events = never> {
56
+ export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never, never>, Events = never> {
58
57
  /** The assistant's role and rules. The loop places `Instructions.protocol` before them. */
59
58
  readonly instructions: string | Instructions.Resolved
60
59
  /**
61
60
  * Tools the model can use in addition to the assistant's own. This is an `effect/ai` toolkit,
62
61
  * and its handlers come from the layer. Combine several toolkits with `Toolkit.merge`, for
63
- * example `Memory.Tools` or a `ToolDiscovery.make(...)` toolkit together with your own.
62
+ * example `Memory.Tools`, a `Skill.toolkit(...)`, or a `ToolDiscovery.make(...)` toolkit together
63
+ * with your own.
64
64
  */
65
65
  readonly toolkit?: Toolkit.Toolkit<T> | undefined
66
- /**
67
- * Playbooks the model can follow. The prompt lists them as a catalog, and the model loads one
68
- * with `load_skill` when a task matches it. A skill made with `tools` is left out when the turn's
69
- * tools lack one of them.
70
- */
71
- readonly skills?: ReadonlyArray<Skill<RS>> | undefined
72
66
  /** The maximum number of model calls per turn. Defaults to 16. */
73
67
  readonly maxSteps?: number | undefined
74
68
  /** The message the reply pass gives the model when the turn would end without a reply. */
@@ -188,15 +182,15 @@ export interface Turn<Events = never> {
188
182
  readonly instructions: (instructions: string | Instructions.Resolved) => Instructions.Resolved
189
183
  /**
190
184
  * Runs the agent loop over the chat. The loop uses `Instructions.protocol`, the assistant's own
191
- * tools, and the instructions, tools, and skills in `options`. Every step is a tool call, and
185
+ * tools, and the instructions and tools in `options`. Every step is a tool call, and
192
186
  * the loop ends with `replyIfOwed`.
193
187
  */
194
- readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>, RS = never>(
195
- options: LoopOptions<T, RS, Events>
188
+ readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>>(
189
+ options: LoopOptions<T, Events>
196
190
  ) => Stream.Stream<
197
191
  AssistantLoop.Event,
198
192
  AiError.AiError,
199
- LanguageModel.LanguageModel | TurnServices | AssistantLoop.ToolkitServices<T> | RS
193
+ LanguageModel.LanguageModel | TurnServices | AssistantLoop.ToolkitServices<T>
200
194
  >
201
195
  /**
202
196
  * Runs one more pass when the turn would otherwise end without a reply to the user's latest
@@ -140,13 +140,30 @@ export class SpecialistFinished extends Schema.TaggedClass<SpecialistFinished>()
140
140
  error: Schema.optional(Schema.String)
141
141
  }) {}
142
142
 
143
+ /**
144
+ * Represents the user's decision on a tool call that waits for their approval: `"approved"`, so
145
+ * the call runs, or `"rejected"`, with an optional short reason the model reads.
146
+ *
147
+ * Its id is fixed per call (see `ToolApproval.approve`), so a decision is stored once. A decision on
148
+ * a call that isn't waiting any more (decided, or expired) isn't stored.
149
+ *
150
+ * @category schemas
151
+ * @since 0.4.0
152
+ */
153
+ export class ApprovalDecision extends Schema.TaggedClass<ApprovalDecision>()("ApprovalDecision", {
154
+ id: InputId,
155
+ callId: Schema.String,
156
+ decision: Schema.Literals(["approved", "rejected"]),
157
+ reason: Schema.optional(Schema.String.check(Schema.isMaxLength(500)))
158
+ }) {}
159
+
143
160
  /**
144
161
  * Schema for every input `Assistant.submit` accepts.
145
162
  *
146
163
  * @category schemas
147
164
  * @since 0.1.0
148
165
  */
149
- export const Input = Schema.Union([UserMessage, BlockAnswer, SpecialistFinished])
166
+ export const Input = Schema.Union([UserMessage, BlockAnswer, SpecialistFinished, ApprovalDecision])
150
167
 
151
168
  /**
152
169
  * Represents an input to an assistant.
@@ -157,7 +174,8 @@ export const Input = Schema.Union([UserMessage, BlockAnswer, SpecialistFinished]
157
174
  export type Input = typeof Input.Type
158
175
 
159
176
  /**
160
- * Schema for the inputs from the chat's user, which are messages and block answers.
177
+ * Schema for the inputs from the chat's user, which are messages, block answers, and decisions on
178
+ * tool calls that wait for their approval.
161
179
  *
162
180
  * A turn owes each one a reply. It is also what a chat client usually may submit, since the app's
163
181
  * events come from the server.
@@ -165,7 +183,7 @@ export type Input = typeof Input.Type
165
183
  * @category schemas
166
184
  * @since 0.1.0
167
185
  */
168
- export const UserInput = Schema.Union([UserMessage, BlockAnswer])
186
+ export const UserInput = Schema.Union([UserMessage, BlockAnswer, ApprovalDecision])
169
187
 
170
188
  /**
171
189
  * Represents an input from the chat's user.
@@ -179,12 +197,16 @@ export type UserInput = typeof UserInput.Type
179
197
  * Represents the UI shown with a message, as a block kind from the assistant's `BlockKit` and its
180
198
  * props.
181
199
  *
200
+ * A block that shows a tool's result (`Block.fromToolResult`) has the tool's name as its kind and
201
+ * the result's `resultId`.
202
+ *
182
203
  * @category schemas
183
204
  * @since 0.1.0
184
205
  */
185
206
  export class MessageBlock extends Schema.Class<MessageBlock>("effect-assistant/MessageBlock")({
186
207
  kind: Schema.String,
187
- props: Schema.Unknown
208
+ props: Schema.Unknown,
209
+ resultId: Schema.optional(Schema.String)
188
210
  }) {}
189
211
 
190
212
  /**
@@ -307,6 +329,10 @@ export type ToolOutcome = typeof ToolOutcome.Type
307
329
  * failure includes its reason when it has one, which is a `ToolFailure`'s `reason` or the
308
330
  * failure's tag.
309
331
  *
332
+ * When the model reads less than the whole success (a `ToolResult.modelView`, or the `resultId`
333
+ * added for a tool the assistant's blocks show), `full` holds the whole success, encoded by the
334
+ * tool's success schema.
335
+ *
310
336
  * @category schemas
311
337
  * @since 0.1.0
312
338
  */
@@ -315,7 +341,10 @@ export class ToolResult extends Schema.TaggedClass<ToolResult>()("ToolResult", {
315
341
  tool: Schema.String,
316
342
  outcome: ToolOutcome,
317
343
  result: Schema.Json,
318
- reason: Schema.optional(Schema.String)
344
+ reason: Schema.optional(Schema.String),
345
+ full: Schema.optional(Schema.Json),
346
+ /** The id the model passes in a `tool_result` block to show this result. */
347
+ resultId: Schema.optional(Schema.String)
319
348
  }) {}
320
349
 
321
350
  /**
@@ -400,6 +429,36 @@ export class BlocksClosed extends Schema.TaggedClass<BlocksClosed>()("BlocksClos
400
429
  messageIds: Schema.Array(Schema.String)
401
430
  }) {}
402
431
 
432
+ /**
433
+ * Represents a tool call that waits for the user's approval instead of running: its tool needs
434
+ * approval (Effect's `needsApproval`).
435
+ *
436
+ * **Details**
437
+ *
438
+ * It holds the call's id, the tool, and its params, in JSON, as the model wrote them. The model
439
+ * reads it as the call's result, saying it waits for the user; the user's
440
+ * {@link ApprovalDecision} decides it, and an approved call runs then, with the same id.
441
+ *
442
+ * @category schemas
443
+ * @since 0.4.0
444
+ */
445
+ export class ApprovalRequested extends Schema.TaggedClass<ApprovalRequested>()("ApprovalRequested", {
446
+ callId: Schema.String,
447
+ tool: Schema.String,
448
+ params: Schema.Json
449
+ }) {}
450
+
451
+ /**
452
+ * Represents tool calls that waited for the user's approval and won't run: the user moved on, like
453
+ * the ones `ToolApproval.closeStale` expires. The model reads that they weren't approved.
454
+ *
455
+ * @category schemas
456
+ * @since 0.4.0
457
+ */
458
+ export class ApprovalsExpired extends Schema.TaggedClass<ApprovalsExpired>()("ApprovalsExpired", {
459
+ callIds: Schema.Array(Schema.String)
460
+ }) {}
461
+
403
462
  /**
404
463
  * Schema for every event the library defines.
405
464
  *
@@ -419,6 +478,9 @@ export const AssistantEvent = Schema.Union([
419
478
  SpecialistStarted,
420
479
  SpecialistFinished,
421
480
  BlocksClosed,
481
+ ApprovalRequested,
482
+ ApprovalDecision,
483
+ ApprovalsExpired,
422
484
  ChatDeleted
423
485
  ])
424
486
 
@@ -554,7 +616,7 @@ export type Entry = typeof Entry.Type
554
616
 
555
617
  /**
556
618
  * Returns whether an event is one of the library's inputs, which are a user message, a block
557
- * answer, or a finished specialist run.
619
+ * answer, a finished specialist run, or a decision on a tool call waiting for approval.
558
620
  *
559
621
  * The application's events that came in through the assistant's `submit` are inputs too (see
560
622
  * {@link isAppInput}).
@@ -565,7 +627,8 @@ export type Entry = typeof Entry.Type
565
627
  export const isInput = (event: { readonly _tag: string }): event is Input =>
566
628
  Predicate.isTagged(event, "UserMessage") ||
567
629
  Predicate.isTagged(event, "BlockAnswer") ||
568
- Predicate.isTagged(event, "SpecialistFinished")
630
+ Predicate.isTagged(event, "SpecialistFinished") ||
631
+ Predicate.isTagged(event, "ApprovalDecision")
569
632
 
570
633
  /**
571
634
  * Returns whether an input starts a turn, which every input does except a run the assistant
@@ -584,7 +647,9 @@ export const isWaking = (input: Input): boolean =>
584
647
  * @since 0.1.0
585
648
  */
586
649
  export const isUserInput = (input: Input): input is UserInput =>
587
- Predicate.isTagged(input, "UserMessage") || Predicate.isTagged(input, "BlockAnswer")
650
+ Predicate.isTagged(input, "UserMessage") ||
651
+ Predicate.isTagged(input, "BlockAnswer") ||
652
+ Predicate.isTagged(input, "ApprovalDecision")
588
653
 
589
654
  /**
590
655
  * Returns whether an event ends a turn, and so says how far the assistant has read.
@@ -161,8 +161,10 @@ const toolsOf = <T extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<T>
161
161
  * the step's result and decides what comes next, with `AssistantLoop.next(state)` or
162
162
  * `AssistantLoop.stop()`.
163
163
  *
164
- * A tool with Effect's `needsApproval` set fails the loop before the model is called: the
165
- * library doesn't run calls that wait for approval (see the guide on involving the user).
164
+ * In a turn's loop, a call to a tool with Effect's `needsApproval` doesn't run: the step emits an
165
+ * `ApprovalRequested` for it, and ends the turn, which waits for the user (see `ToolApproval`). A
166
+ * call the user approved runs at the start of the next step, before the model is called. A
167
+ * specialist's run can't wait for the user: such a call fails, and the run reads why.
166
168
  *
167
169
  * @category constructors
168
170
  * @since 0.1.0
package/src/Block.ts CHANGED
@@ -5,9 +5,14 @@
5
5
  * pick from, a yes or no, or a small input. The assistant fills in its `props`, and
6
6
  * using the block sends back a value typed by its `answer` schema.
7
7
  *
8
+ * A block made with {@link fromToolResult} shows a tool's result instead: the assistant picks the
9
+ * result, and the client draws it from what the tool returned.
10
+ *
8
11
  * @since 0.1.0
9
12
  */
10
- import type * as Schema from "effect/Schema"
13
+ import type * as Tool from "effect/ai/Tool"
14
+ import * as Predicate from "effect/Predicate"
15
+ import * as Schema from "effect/Schema"
11
16
 
12
17
  /**
13
18
  * Schema that a block's props or answer can use.
@@ -82,3 +87,79 @@ export const make = <const Kind extends string, Props extends Codec, Answer exte
82
87
  props: options.props,
83
88
  answer: options.answer
84
89
  })
90
+
91
+ /**
92
+ * Schema for the props of a block that shows a tool's result: none, since the result is the block.
93
+ *
94
+ * @category schemas
95
+ * @since 0.5.0
96
+ */
97
+ export const NoProps = Schema.Struct({})
98
+
99
+ /**
100
+ * Represents a block that shows a tool's results, made with {@link fromToolResult}. Its kind is the
101
+ * tool's name.
102
+ *
103
+ * @category models
104
+ * @since 0.5.0
105
+ */
106
+ export interface ToolResultBlock<T extends Tool.Any = Tool.Any> extends Block<
107
+ Tool.Name<T>,
108
+ typeof NoProps,
109
+ Schema.Never
110
+ > {
111
+ readonly tool: T
112
+ /** The tool's success schema, which the client decodes the result with. */
113
+ readonly result: Codec
114
+ }
115
+
116
+ /**
117
+ * Creates a block that shows a tool's results.
118
+ *
119
+ * **Details**
120
+ *
121
+ * Put it in the assistant's `BlockKit`. The tool's results then carry a `resultId` in what the model
122
+ * reads, and the model shows one with a `tool_result` block that names it: it never sees the
123
+ * block's kind or how the client draws it. Sending the block fails back to the model when no
124
+ * successful result of a shown tool has that id.
125
+ *
126
+ * On the client, `BlockKit.toClient` gives the block's renderer the result, decoded by the tool's
127
+ * success schema. The tool's definition is shared with the client for that, so it can't need
128
+ * server-only services: add those on the server with `Tool.addDependency`.
129
+ *
130
+ * **Example** (Showing search results as cards)
131
+ *
132
+ * ```ts
133
+ * import { Block, BlockKit } from "effect-assistant"
134
+ * import { SearchAssets } from "./tools.ts"
135
+ *
136
+ * export const Blocks = BlockKit.make([PickOne, Block.fromToolResult(SearchAssets)])
137
+ *
138
+ * // Client
139
+ * const renderBlock = BlockKit.toClient<typeof Blocks, ReactNode>(Blocks, {
140
+ * pick_one: ({ props, respond }) => <Options options={props.options} onPick={respond} />,
141
+ * search_assets: ({ result }) => <AssetCards assets={result.items} />
142
+ * })
143
+ * ```
144
+ *
145
+ * @category constructors
146
+ * @since 0.5.0
147
+ */
148
+ export const fromToolResult = <T extends Tool.Any>(
149
+ tool: T & { readonly successSchema: Codec }
150
+ ): ToolResultBlock<T> => ({
151
+ kind: tool.name,
152
+ description: `Shows a ${tool.name} result.`,
153
+ props: NoProps,
154
+ answer: undefined,
155
+ tool,
156
+ result: tool.successSchema
157
+ })
158
+
159
+ /**
160
+ * Returns whether a block shows a tool's results.
161
+ *
162
+ * @category guards
163
+ * @since 0.5.0
164
+ */
165
+ export const isToolResult = (block: Any): block is ToolResultBlock => Predicate.hasProperty(block, "tool")
package/src/BlockKit.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  *
9
9
  * @since 0.1.0
10
10
  */
11
+ import type * as Tool from "effect/ai/Tool"
11
12
  import * as Effect from "effect/Effect"
12
13
  import * as Option from "effect/Option"
13
14
  import * as Predicate from "effect/Predicate"
14
15
  import * as Schema from "effect/Schema"
15
16
  import * as AssistantEvent from "./AssistantEvent.ts"
16
- import type * as Block from "./Block.ts"
17
+ import * as Block from "./Block.ts"
17
18
  import type * as Timeline from "./Timeline.ts"
18
19
  import { CurrentChat } from "./CurrentChat.ts"
19
20
  import * as InputId from "./InputId.ts"
@@ -40,6 +41,15 @@ export const make = <const Blocks extends ReadonlyArray<Block.Any>>(blocks: Bloc
40
41
  byKind: new Map(blocks.map((block) => [block.kind, block]))
41
42
  })
42
43
 
44
+ /**
45
+ * Returns the names of the tools whose results the kit's blocks show (`Block.fromToolResult`).
46
+ *
47
+ * @category utils
48
+ * @since 0.5.0
49
+ */
50
+ export const toolNames = (kit: BlockKit | undefined): ReadonlySet<string> =>
51
+ new Set((kit?.blocks ?? []).flatMap((block) => (Block.isToolResult(block) ? [block.tool.name] : [])))
52
+
43
53
  type KindOf<B> = B extends { readonly kind: infer K extends string } ? K : never
44
54
 
45
55
  type BlockOf<Kit extends BlockKit, K> = Extract<Kit["blocks"][number], { readonly kind: K }>
@@ -48,6 +58,13 @@ type PropsOf<B> = B extends { readonly props: Schema.Top } ? B["props"]["Type"]
48
58
 
49
59
  type AnswerOf<B> = B extends { readonly answer: infer A } ? (A extends Schema.Top ? A["Type"] : never) : never
50
60
 
61
+ type ResultArgs<B> = B extends { readonly tool: infer T }
62
+ ? {
63
+ /** The tool's result the block shows, decoded by its success schema. */
64
+ readonly result: Tool.Success<T>
65
+ }
66
+ : unknown
67
+
51
68
  /**
52
69
  * Represents the state of a message's block.
53
70
  *
@@ -164,12 +181,21 @@ export const closeStale = Effect.fn(function* (options: { readonly messages: num
164
181
  })
165
182
 
166
183
  /**
167
- * Represents what a renderer receives for one block.
184
+ * Represents what a renderer receives for one block. A block that shows a tool's result also gets
185
+ * the `result`.
168
186
  *
169
187
  * @category models
170
188
  * @since 0.1.0
171
189
  */
172
- export interface RenderArgs<B> {
190
+ export type RenderArgs<B> = BlockArgs<B> & ResultArgs<B>
191
+
192
+ /**
193
+ * Represents what every block's renderer receives.
194
+ *
195
+ * @category models
196
+ * @since 0.5.0
197
+ */
198
+ export interface BlockArgs<B> {
173
199
  /**
174
200
  * The id of the message the block is on. A block whose button calls your API can key the work
175
201
  * on it, with the chat's id, so the work runs once per block.
@@ -256,7 +282,7 @@ export function toClient<Kit extends BlockKit, Out>(
256
282
  // arguments.
257
283
  export function toClient<Out>(
258
284
  kit: BlockKit,
259
- renderers: Readonly<Record<string, (args: RenderArgs<Block.Any>) => Out>>
285
+ renderers: Readonly<Record<string, (args: BlockArgs<Block.Any> & { readonly result?: unknown }) => Out>>
260
286
  ): (message: Timeline.AssistantMessage, options: RenderOptions) => Out | undefined {
261
287
  return (message, options) => {
262
288
  if (message.block === undefined) return undefined
@@ -271,6 +297,12 @@ export function toClient<Out>(
271
297
 
272
298
  if (Option.isNone(props)) return undefined
273
299
 
300
+ const result = Block.isToolResult(block)
301
+ ? Schema.decodeUnknownOption(Schema.toCodecJson(block.result))(message.block.result)
302
+ : Option.some(undefined)
303
+
304
+ if (Option.isNone(result)) return undefined
305
+
274
306
  const given = message.block.answer
275
307
  const answerCodec = block.answer === undefined ? undefined : Schema.toCodecJson(block.answer)
276
308
 
@@ -281,6 +313,7 @@ export function toClient<Out>(
281
313
  messageId: message.id,
282
314
  text: message.text,
283
315
  props: props.value,
316
+ result: result.value,
284
317
  open: message.block.status === "open",
285
318
  answer: Option.getOrUndefined(answer),
286
319
  respond: (value) =>