@setluca/mcp 0.2.0 → 0.3.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 (72) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -258
  4. package/dist/annotations.js +35 -20
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +44 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +99 -22
  9. package/dist/fields.js +68 -0
  10. package/dist/fixed-window.js +46 -0
  11. package/dist/generated/openapi-inputs.js +216 -5820
  12. package/dist/generated/openapi-outputs.js +19014 -14001
  13. package/dist/generated/route-catalog.js +80 -0
  14. package/dist/http.js +108 -44
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-challenge.js +74 -0
  17. package/dist/oauth-resolver.js +58 -61
  18. package/dist/observability.js +31 -16
  19. package/dist/openapi-schema.js +175 -0
  20. package/dist/operations/catalog.js +17 -0
  21. package/dist/operations/groups/analytics.js +20 -10
  22. package/dist/operations/groups/bookings.js +43 -15
  23. package/dist/operations/groups/broadcasts.js +29 -58
  24. package/dist/operations/groups/cadences.js +3 -1
  25. package/dist/operations/groups/callEvents.js +19 -72
  26. package/dist/operations/groups/campaigns.js +28 -32
  27. package/dist/operations/groups/channels.js +23 -0
  28. package/dist/operations/groups/conversations.js +14 -25
  29. package/dist/operations/groups/insights.js +4 -0
  30. package/dist/operations/groups/integrations.js +15 -17
  31. package/dist/operations/groups/knowledge.js +5 -1
  32. package/dist/operations/groups/leads.js +22 -23
  33. package/dist/operations/groups/learning.js +13 -1
  34. package/dist/operations/groups/memory.js +4 -6
  35. package/dist/operations/groups/reports.js +1 -1
  36. package/dist/operations/groups/reviewQueue.js +17 -14
  37. package/dist/operations/groups/safety.js +1 -1
  38. package/dist/operations/groups/voice.js +0 -1
  39. package/dist/operations/groups/webhooks.js +16 -4
  40. package/dist/operations/manifest.js +9 -8
  41. package/dist/operations/registry.js +110 -116
  42. package/dist/operations.js +6 -5
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +149 -68
  46. package/dist/prompts.js +139 -88
  47. package/dist/provenance.js +2 -2
  48. package/dist/rate-limit.js +40 -0
  49. package/dist/remote.js +145 -65
  50. package/dist/resilience.js +120 -55
  51. package/dist/resolvers.js +125 -0
  52. package/dist/resources.js +17 -2
  53. package/dist/scopes.js +207 -0
  54. package/dist/serialization.js +32 -22
  55. package/dist/server.js +174 -191
  56. package/dist/standard-schema.js +215 -0
  57. package/dist/task-tools/analytics.js +145 -86
  58. package/dist/task-tools/bookings.js +176 -86
  59. package/dist/task-tools/cadences.js +30 -45
  60. package/dist/task-tools/calls.js +132 -125
  61. package/dist/task-tools/inbox.js +89 -53
  62. package/dist/task-tools/leads.js +49 -77
  63. package/dist/task-tools/runtime.js +80 -20
  64. package/dist/task-tools.js +1 -1
  65. package/dist/token-verification.js +42 -0
  66. package/dist/tool-cache.js +3 -2
  67. package/dist/tool.js +155 -100
  68. package/dist/version.js +1 -1
  69. package/package.json +40 -18
  70. package/dist/openapi-zod.js +0 -333
  71. package/dist/page-shape.js +0 -47
  72. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,30 +1,35 @@
1
- import { Effect, Option, Schema } from "effect";
2
- import * as z from "zod/v4";
3
- import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Schema from "effect/Schema";
4
+ import { isNotFound, LucaToolInputError } from "../errors.js";
5
+ import { IanaTimeZone, IsoDateTime, NonEmptyString, WORKSPACE_INPUT_FIELDS, } from "../fields.js";
4
6
  import { optionalField } from "../optional-field.js";
5
- import { independentWrite, stringInput, taskRequest, textBody, } from "./runtime.js";
6
- const postCallQueue = {
7
+ import { failureAsField, idempotencyKeyInput, operationResult, resultOrError, stringInput, taskTool, textBody, } from "./runtime.js";
8
+ const isNonEmptyString = Schema.is(NonEmptyString);
9
+ const postCallQueue = taskTool({
7
10
  name: "luca_post_call_queue",
8
11
  title: "List the post-call work",
9
12
  description: "Return everything waiting on the coach after a call: calls that still need a report on how they went, and attended calls with no revenue outcome recorded. The two halves are tracked separately by the API, so a call can sit on one list and not the other. Close an item with luca_close_call_loop. Example: call with {} after a day of calls.",
10
13
  inputSchema: {
11
14
  ...WORKSPACE_INPUT_FIELDS,
12
15
  },
13
- readOnly: true,
14
- untrustedContent: true,
16
+ outputSchema: Schema.Struct({
17
+ needingFeedback: operationResult("callEvents.needingFeedback"),
18
+ needingOutcome: operationResult("bookings.needsOutcome"),
19
+ }),
15
20
  composes: ["callEvents.needingFeedback", "bookings.needsOutcome"],
16
- run: (api, operationById, input) =>
21
+ run: (call) =>
17
22
  // Neither read depends on the other, and Effect.all defaults to
18
23
  // concurrency 1, so without this the second waits on the first for nothing.
19
24
  Effect.all({
20
- needingFeedback: api.request(taskRequest(operationById("callEvents.needingFeedback"), input)),
21
- needingOutcome: api.request(taskRequest(operationById("bookings.needsOutcome"), input)),
25
+ needingFeedback: call("callEvents.needingFeedback"),
26
+ needingOutcome: call("bookings.needsOutcome"),
22
27
  }, { concurrency: "unbounded" }),
23
- };
28
+ });
24
29
  /**
25
30
  * The id of the call event attached to a booking, if the API has one. A booking
26
31
  * whose call has not happened yet answers 404, which is the normal case rather
27
- * than a failure — so is any other read error here, because the outcome write
32
+ * than a failure. So is any other read error here, because the outcome write
28
33
  * below is worth doing on its own.
29
34
  */
30
35
  const CallEventId = Schema.Struct({
@@ -38,6 +43,25 @@ const ATTENDANCE_VALUES = [
38
43
  "cancelled",
39
44
  ];
40
45
  const OUTCOME_VALUES = ["won", "lost", "open"];
46
+ const decodeOutcomeStatus = Schema.decodeUnknownOption(Schema.Literals(OUTCOME_VALUES));
47
+ /**
48
+ * A field the chosen outcome status makes mandatory. The tool schema marks all
49
+ * three optional because which one is required depends on `outcome`, and a flat
50
+ * field list cannot say that, so the check lands here.
51
+ *
52
+ * It has to reach the error channel rather than throw. `outcomeBody` is built
53
+ * as an argument to the request the tool sends, so a throw would escape the
54
+ * `failureAsField` around that request and surface as a defect — after the
55
+ * attendance report had already landed, which is the one thing that call is
56
+ * there to prevent.
57
+ */
58
+ function requiredFor(status, field, value) {
59
+ return isNonEmptyString(value)
60
+ ? Effect.succeed(value)
61
+ : Effect.fail(new LucaToolInputError({
62
+ message: `${field} is required when outcome is ${status}.`,
63
+ }));
64
+ }
41
65
  /**
42
66
  * The revenue half of the body, shaped by the status it carries. The API takes
43
67
  * one of three variants and rejects a mix, so building it here keeps the tool
@@ -50,153 +74,136 @@ function outcomeBody(input) {
50
74
  ...optionalField("expectedVersion", input.expectedVersion),
51
75
  };
52
76
  if (input.status === "won") {
53
- return {
77
+ return Effect.succeed({
54
78
  ...common,
55
79
  ...optionalField("amountMinor", input.amountMinor),
56
80
  ...textBody("currency", input.currency),
57
- };
81
+ });
58
82
  }
59
83
  if (input.status === "lost") {
60
- return {
61
- ...common,
62
- lossReasonDefinitionId: stringInput(input.lossReasonDefinitionId),
63
- };
84
+ return Effect.map(requiredFor("lost", "lossReasonDefinitionId", input.lossReasonDefinitionId), (lossReasonDefinitionId) => ({ ...common, lossReasonDefinitionId }));
64
85
  }
65
- return {
66
- ...common,
67
- nextFollowUpAt: stringInput(input.nextFollowUpAt),
68
- followUpTimezone: stringInput(input.followUpTimezone),
69
- };
86
+ return Effect.map(Effect.all({
87
+ nextFollowUpAt: requiredFor("open", "nextFollowUpAt", input.nextFollowUpAt),
88
+ followUpTimezone: requiredFor("open", "followUpTimezone", input.followUpTimezone),
89
+ }, { concurrency: 1 }), (followUp) => ({ ...common, ...followUp }));
70
90
  }
71
- const closeCallLoop = {
91
+ const closeCallLoop = taskTool({
72
92
  name: "luca_close_call_loop",
73
93
  title: "Close the loop on a call",
74
- description: 'File what happened on a call and what it was worth, in one call. Reports attendance on the call event (completed, no_show, rescheduled, or cancelled) and, when an outcome is given, records the booking outcome: won with an amount, lost with a reason id from luca_bookings_outcomeReasons_list, or open with the next follow-up. Nothing is sent to the lead. Example: { bookingId, attendance: "completed", outcome: "won", amountMinor: 250000, currency: "USD" }.',
94
+ description: 'File what happened on a call and what it was worth, in one call. Reports attendance on the call event (completed, no_show, rescheduled, or cancelled) and, when an outcome is given, records the booking outcome: won with an amount, lost with a reason id from luca_bookings_outcome_reasons_list, or open with the next follow-up. This tool sends no message to the lead itself, but the records it files start work in the API. Reporting a no_show can start no-show recovery, which schedules a message to the lead when the booking type has recovery on, so pass confirm: true only after the coach says to report the no-show. A completed call queues its summary, which can later sync to the connected CRM. A won or lost outcome queues a CRM deal-stage update, and lost also moves the lead to lost. Example: { bookingId, attendance: "completed", outcome: "won", amountMinor: 250000, currency: "USD" }.',
95
+ // Only a no_show can message the lead (through recovery), so only that
96
+ // report needs approval. The other attendance values stay ungated.
97
+ confirm: {
98
+ applies: (input) => input.attendance === "no_show",
99
+ description: "attendance is no_show (can start recovery messaging)",
100
+ },
75
101
  inputSchema: {
76
102
  ...WORKSPACE_INPUT_FIELDS,
77
- bookingId: z
78
- .string()
79
- .min(1)
80
- .describe("The booking whose call just happened."),
81
- attendance: z
82
- .enum(ATTENDANCE_VALUES)
83
- .describe("What happened to the call itself."),
84
- rating: z
85
- .number()
86
- .int()
87
- .min(1)
88
- .max(5)
89
- .optional()
90
- .describe("The coach's 1-5 rating of the call."),
91
- notes: z
92
- .string()
93
- .min(1)
94
- .max(4000)
95
- .optional()
96
- .describe("The coach's notes on the call, stored on the call event."),
97
- outcome: z
98
- .enum(OUTCOME_VALUES)
99
- .optional()
100
- .describe("The revenue outcome. Leave it off to report attendance only and record the money later."),
101
- amountMinor: z
102
- .number()
103
- .int()
104
- .min(0)
105
- .optional()
106
- .describe("Deal value in minor units (cents) when outcome is won."),
107
- currency: z
108
- .string()
109
- .length(3)
110
- .optional()
111
- .describe("ISO-4217 currency for amountMinor, uppercase."),
112
- lossReasonDefinitionId: z
113
- .string()
114
- .optional()
115
- .describe("Required when outcome is lost: a reason id from luca_bookings_outcomeReasons_list."),
116
- nextFollowUpAt: z
117
- .string()
118
- .optional()
119
- .describe("Required when outcome is open: ISO-8601 follow-up time."),
120
- followUpTimezone: z
121
- .string()
122
- .optional()
123
- .describe("Required when outcome is open: IANA timezone the follow-up is scheduled in."),
124
- outcomeNote: z
125
- .string()
126
- .min(1)
127
- .max(5000)
128
- .optional()
129
- .describe("Note stored on the revenue outcome, separate from notes."),
130
- expectedVersion: z
131
- .number()
132
- .int()
133
- .min(0)
134
- .optional()
135
- .describe("The outcome version you read, when correcting an earlier outcome. A stale version returns 409 with the current one."),
136
- idempotencyKey: z
137
- .string()
138
- .optional()
139
- .describe("Reuse the same key when retrying this exact report. Both writes are scoped by route, so one key covers the pair."),
103
+ bookingId: NonEmptyString.annotate({
104
+ description: "The booking whose call just happened.",
105
+ }),
106
+ attendance: Schema.Literals(ATTENDANCE_VALUES).annotate({
107
+ description: "What happened to the call itself.",
108
+ }),
109
+ rating: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 })).annotate({
110
+ description: "The coach's 1-5 rating of the call.",
111
+ })),
112
+ notes: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(4000)).annotate({
113
+ description: "The coach's notes on the call, stored on the call event.",
114
+ })),
115
+ outcome: Schema.optionalKey(Schema.Literals(OUTCOME_VALUES).annotate({
116
+ description: "The revenue outcome. Leave it off to report attendance only and record the money later.",
117
+ })),
118
+ amountMinor: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({
119
+ description: "Deal value in minor units (cents) when outcome is won.",
120
+ })),
121
+ currency: Schema.optionalKey(Schema.String.check(Schema.isMinLength(3), Schema.isMaxLength(3)).annotate({
122
+ description: "ISO-4217 currency for amountMinor, uppercase.",
123
+ })),
124
+ lossReasonDefinitionId: Schema.optionalKey(Schema.String.annotate({
125
+ description: "Required when outcome is lost: a reason id from luca_bookings_outcome_reasons_list.",
126
+ })),
127
+ nextFollowUpAt: Schema.optionalKey(IsoDateTime.annotate({
128
+ description: "Required when outcome is open: ISO-8601 follow-up time.",
129
+ })),
130
+ followUpTimezone: Schema.optionalKey(IanaTimeZone.annotate({
131
+ description: "Required when outcome is open: IANA timezone the follow-up is scheduled in.",
132
+ })),
133
+ outcomeNote: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(5000)).annotate({
134
+ description: "Note stored on the revenue outcome, separate from notes.",
135
+ })),
136
+ expectedVersion: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({
137
+ description: "The outcome version you read, when correcting an earlier outcome. A stale version returns 409 with the current one.",
138
+ })),
139
+ ...idempotencyKeyInput("Reuse the same key when retrying this exact report. Both writes are scoped by route, so one key covers the pair."),
140
140
  },
141
- readOnly: false,
142
- untrustedContent: true,
141
+ outputSchema: Schema.Struct({
142
+ feedback: Schema.Union([
143
+ resultOrError("callEvents.feedback", "feedbackError"),
144
+ Schema.Struct({
145
+ reported: Schema.Literal(false),
146
+ reason: Schema.Literal("no_call_event_for_booking"),
147
+ }),
148
+ ]),
149
+ outcome: Schema.optionalKey(resultOrError("bookings.outcome.record", "outcomeError")),
150
+ }),
143
151
  composes: [
144
152
  "callEvents.byBooking",
145
153
  "callEvents.feedback",
146
154
  "bookings.outcome.record",
147
155
  ],
148
- run: (api, operationById, input) => Effect.gen(function* () {
156
+ run: (call, input) => Effect.gen(function* () {
149
157
  const bookingId = stringInput(input.bookingId);
150
- const page = yield* Effect.catchAll(api.request(taskRequest(operationById("callEvents.byBooking"), input, {
151
- pathParams: { bookingId },
152
- })), () => Effect.succeed(null));
153
- const feedback = yield* Option.match(decodeCallEventId(page), {
158
+ const outcomeStatus = decodeOutcomeStatus(input.outcome);
159
+ // Built before either write. A revenue body this tool cannot assemble is
160
+ // a bad call rather than a failed write, and refusing it up front leaves
161
+ // nothing half-filed for the agent's retry to duplicate.
162
+ const body = Option.isSome(outcomeStatus)
163
+ ? yield* outcomeBody({
164
+ status: outcomeStatus.value,
165
+ note: input.outcomeNote,
166
+ amountMinor: input.amountMinor,
167
+ currency: input.currency,
168
+ lossReasonDefinitionId: input.lossReasonDefinitionId,
169
+ nextFollowUpAt: input.nextFollowUpAt,
170
+ followUpTimezone: input.followUpTimezone,
171
+ expectedVersion: input.expectedVersion,
172
+ })
173
+ : undefined;
174
+ // The revenue write below is worth doing even when the attendance half
175
+ // fails, so a failed lookup or report becomes a field rather than ending
176
+ // the tool call. Only a 404 means the booking has no call event; any
177
+ // other failure is reported as one, not mistaken for that answer.
178
+ const feedback = yield* failureAsField("feedbackError", call("callEvents.byBooking", { pathParams: { bookingId } }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(null)), Effect.flatMap((page) => Option.match(decodeCallEventId(page), {
154
179
  onNone: () =>
155
- // No call event means attendance has nowhere to go. The revenue write
156
- // below still runs — that is the half the coach is usually after.
180
+ // No call event means attendance has nowhere to go. The
181
+ // revenue write below still runs, and that is the half the
182
+ // coach is usually after.
157
183
  Effect.succeed({
158
184
  reported: false,
159
185
  reason: "no_call_event_for_booking",
160
186
  }),
161
- onSome: (decoded) =>
162
- // The revenue write below is worth doing even when the attendance
163
- // report fails, so this failure becomes a field rather than ending
164
- // the tool call.
165
- independentWrite("feedbackError", api.request(taskRequest(operationById("callEvents.feedback"), input, {
187
+ onSome: (decoded) => call("callEvents.feedback", {
166
188
  pathParams: { id: decoded.callEvent.id },
167
189
  body: {
168
190
  outcome: input.attendance,
169
191
  ...optionalField("rating", input.rating),
170
192
  ...textBody("notes", input.notes),
171
193
  },
172
- }))),
173
- });
174
- const outcomeStatus = z
175
- .enum(OUTCOME_VALUES)
176
- .safeParse(input.outcome ?? undefined);
177
- if (!outcomeStatus.success) {
194
+ }),
195
+ }))));
196
+ if (body === undefined) {
178
197
  return { feedback };
179
198
  }
180
199
  // The attendance report already landed. Failing the whole tool here
181
200
  // would tell the agent nothing was written and invite a retry that files
182
201
  // the attendance twice under a fresh key.
183
- const outcome = yield* independentWrite("outcomeError", api.request(taskRequest(operationById("bookings.outcome.record"), input, {
202
+ const outcome = yield* failureAsField("outcomeError", call("bookings.outcome.record", {
184
203
  pathParams: { id: bookingId },
185
- body: outcomeBody({
186
- status: outcomeStatus.data,
187
- note: input.outcomeNote,
188
- amountMinor: input.amountMinor,
189
- currency: input.currency,
190
- lossReasonDefinitionId: input.lossReasonDefinitionId,
191
- nextFollowUpAt: input.nextFollowUpAt,
192
- followUpTimezone: input.followUpTimezone,
193
- expectedVersion: input.expectedVersion,
194
- }),
195
- })));
204
+ body,
205
+ }));
196
206
  return { feedback, outcome };
197
207
  }),
198
- };
199
- export const callTaskTools = [
200
- postCallQueue,
201
- closeCallLoop,
202
- ];
208
+ });
209
+ export const callTaskTools = [postCallQueue, closeCallLoop];
@@ -1,74 +1,110 @@
1
- import { Effect, Record } from "effect";
2
- import * as z from "zod/v4";
1
+ import * as Arr from "effect/Array";
2
+ import * as Effect from "effect/Effect";
3
+ import * as R from "effect/Record";
4
+ import * as Schema from "effect/Schema";
3
5
  import { formatError } from "../errors.js";
4
- import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
5
- import { listingIds, numberInput, taskRequest, } from "./runtime.js";
6
+ import { WORKSPACE_INPUT_FIELDS } from "../fields.js";
7
+ import { logAgentSurfaceEvent } from "../observability.js";
8
+ import { isPartialFailure, listingIds, numberInput, operationResult, resultOrError, taskTool, } from "./runtime.js";
6
9
  const DEFAULT_QUEUE_LIMIT = 20;
7
10
  const DEFAULT_EXPLAIN_TOP = 3;
11
+ const TRIAGE_INBOX_TOOL_NAME = "luca_triage_inbox";
12
+ /**
13
+ * One explanation, with a failed lookup carried in the result instead of
14
+ * cancelling the others. A queue the coach can read with two explanations
15
+ * missing beats no queue at all. An authorization failure still ends the call,
16
+ * see {@link isPartialFailure}.
17
+ */
18
+ function explainOne(call, id) {
19
+ return call("reviewQueue.explain", { pathParams: { id } }).pipe(Effect.map((explanation) => ({
20
+ id,
21
+ explanation,
22
+ failed: false,
23
+ })),
24
+ // Keep the structured formatter, not a lossy String(error): a
25
+ // LucaHttpError's status/requestId survive for debugging a partial
26
+ // failure inside the composed tool.
27
+ Effect.catchIf(isPartialFailure, (error) => Effect.succeed({
28
+ id,
29
+ explanation: { explainError: formatError(error) },
30
+ failed: true,
31
+ })));
32
+ }
33
+ /**
34
+ * How the explain fan-out went, as counts.
35
+ *
36
+ * `mcp.tool.called` reports this tool as a success even when every explanation
37
+ * inside it failed, because a failed lookup comes back as an `explainError`
38
+ * field rather than an error result. Without this line, an explain route that
39
+ * is broken for every caller looks identical in logs to one that works.
40
+ *
41
+ * The single-route explain tools need no line of their own: `mcp.tool.called`
42
+ * already carries their name and outcome. This fan-out is the only place where
43
+ * a failure has nowhere else to show up.
44
+ *
45
+ * Counts only, never an id or an explanation. Both are lead-derived.
46
+ */
47
+ function logExplainFanOut(attempts) {
48
+ if (Arr.isReadonlyArrayEmpty(attempts)) {
49
+ return;
50
+ }
51
+ const failed = attempts.filter((attempt) => attempt.failed).length;
52
+ logAgentSurfaceEvent("mcp.explain", {
53
+ toolName: TRIAGE_INBOX_TOOL_NAME,
54
+ requested: attempts.length,
55
+ explained: attempts.length - failed,
56
+ failed,
57
+ });
58
+ }
8
59
  /** The review session's entry point: the queue, with the top items explained. */
9
- const triageInbox = {
10
- name: "luca_triage_inbox",
60
+ const triageInbox = taskTool({
61
+ name: TRIAGE_INBOX_TOOL_NAME,
11
62
  title: "Triage the review queue",
12
- description: "Return the coach's prioritized reply queue: pending drafts with lead context and, for the top items, why Luca drafted what it drafted. Start every review session here, then act on an item with luca_approve_and_send, luca_draft_reply, or luca_reviewQueue_reject. Example: call with { explainTop: 3 } to get the queue plus explanations for the three most urgent drafts.",
63
+ description: "Return the coach's prioritized reply queue: pending drafts with lead context and, for the top items, why Luca drafted what it drafted. Start every review session here, then act on an item with luca_approve_and_send, luca_draft_reply, or luca_review_queue_reject. Example: call with { explainTop: 3 } to get the queue plus explanations for the three most urgent drafts.",
13
64
  inputSchema: {
14
65
  ...WORKSPACE_INPUT_FIELDS,
15
- limit: z
16
- .number()
17
- .int()
18
- .min(1)
19
- .max(50)
20
- .optional()
21
- .describe("Max queue items to return (default 20)."),
22
- explainTop: z
23
- .number()
24
- .int()
25
- .min(0)
26
- .max(5)
27
- .optional()
28
- .describe("How many of the top items to attach a full explanation to (default 3)."),
66
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 })).annotate({
67
+ description: "Max queue items to return (default 20).",
68
+ })),
69
+ explainTop: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 5 })).annotate({
70
+ description: "How many of the top items to attach a full explanation to (default 3).",
71
+ })),
29
72
  },
30
- readOnly: true,
31
- untrustedContent: true,
73
+ outputSchema: Schema.Struct({
74
+ queue: operationResult("reviewQueue.list"),
75
+ explanations: Schema.Record(Schema.String, resultOrError("reviewQueue.explain", "explainError")),
76
+ }),
32
77
  composes: ["reviewQueue.list", "reviewQueue.explain"],
33
- run: (api, operationById, input) => Effect.gen(function* () {
34
- const queue = yield* api.request(taskRequest(operationById("reviewQueue.list"), input, {
35
- query: { limit: input.limit ?? DEFAULT_QUEUE_LIMIT },
36
- }));
78
+ run: (call, input) => Effect.gen(function* () {
79
+ const queue = yield* call("reviewQueue.list", {
80
+ query: { limit: numberInput(input.limit, DEFAULT_QUEUE_LIMIT) },
81
+ });
37
82
  const ids = listingIds(queue, numberInput(input.explainTop, DEFAULT_EXPLAIN_TOP));
38
- const explained = yield* Effect.forEach(ids, (id) => api
39
- .request(taskRequest(operationById("reviewQueue.explain"), input, {
40
- pathParams: { id },
41
- }))
42
- .pipe(Effect.catchAll((error) =>
43
- // Keep the structured formatter, not a lossy String(error): a
44
- // LucaHttpError's status/requestId survive for debugging a
45
- // partial failure inside the composed tool.
46
- Effect.succeed({ explainError: formatError(error) })), Effect.map((explanation) => [
47
- id,
48
- explanation,
49
- ])),
83
+ const attempts = yield* Effect.forEach(ids, (id) => explainOne(call, id),
50
84
  // Effect.forEach defaults to concurrency 1 (sequential). `explainTop`
51
- // caps this fan-out at 5 independent lookups, so unbounded is fine —
52
- // there's no unbounded caller input that could turn this into a
53
- // thundering herd.
85
+ // caps this fan-out at 5 independent lookups, so unbounded is fine.
86
+ // No caller input can grow it into a thundering herd.
54
87
  { concurrency: "unbounded" });
55
- return { queue, explanations: Record.fromEntries(explained) };
88
+ logExplainFanOut(attempts);
89
+ return {
90
+ queue,
91
+ explanations: R.fromEntries(attempts.map((attempt) => [
92
+ attempt.id,
93
+ attempt.explanation,
94
+ ])),
95
+ };
56
96
  }),
57
- };
97
+ });
58
98
  /** The day's opening read: the last 24h of queue activity, already counted. */
59
- const morningReport = {
99
+ const morningReport = taskTool({
60
100
  name: "luca_morning_report",
61
101
  title: "Get the morning digest",
62
102
  description: "Return the coach's structured last-24h review-queue digest: queued drafts with status and confidence, plus counts by status. No model call — summarize it client-side. Example: call with {} at the start of the day.",
63
103
  inputSchema: {
64
104
  ...WORKSPACE_INPUT_FIELDS,
65
105
  },
66
- readOnly: true,
67
- untrustedContent: true,
106
+ outputSchema: operationResult("reports.morning"),
68
107
  composes: ["reports.morning"],
69
- run: (api, operationById, input) => api.request(taskRequest(operationById("reports.morning"), input)),
70
- };
71
- export const inboxTaskTools = [
72
- triageInbox,
73
- morningReport,
74
- ];
108
+ run: (call) => call("reports.morning"),
109
+ });
110
+ export const inboxTaskTools = [triageInbox, morningReport];