Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
],
"maple/no-effect-die": "error",
"maple/no-ordie-compiled-query": "error",
"maple/no-raw-tool-layer": "error",
"maple/no-react-use-effect": "warn",
"maple/no-record-string-any": "error",
"maple/no-try-catch": "error",
Expand Down
1 change: 1 addition & 0 deletions apps/ai/src/chat/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export const runChatTurn = (input: ChatRunInput) => {
input.usage,
input.model.name,
input.closeOut === true,
agentSessionSpanAttributes(input.model.tags),
)

const toolkit = Toolkit.merge(maple.toolkit, ...(completion === undefined ? [] : [completion.toolkit]))
Expand Down
54 changes: 52 additions & 2 deletions apps/ai/src/chat/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
* The tool and whether the run is an autonomous pass travel together: the turn runner closes a
* pass out itself when `submitted()` stays false, and must never do that to a human follow-up.
*/
import { MAPLE_NATIVE_SESSION_ID_ATTR } from "@maple/domain/gen-ai"
import { OrgId, UserId } from "@maple/domain/primitives"
import { Effect, Schema } from "effect"
import { assert, describe, it } from "vitest"
import { buildDiagnosisCompletion, makeRunUsage, type SubmitDiagnosis } from "./tools"
import { buildDiagnosisCompletion, makeRunUsage, SUBMIT_DIAGNOSIS, type SubmitDiagnosis } from "./tools"
import type { TenantContext } from "@maple/backend/services/auth/tenant-context"
import { makeRecordingTracer } from "@maple/backend/testing/recording-tracer"

const orgId = Schema.decodeSync(OrgId)("org_test")
const human = Schema.decodeSync(UserId)("user_test")
Expand All @@ -27,8 +29,28 @@ const INVESTIGATION_SESSION = `${orgId}:inv-00000000-0000-0000-0000-000000000000

const submitDiagnosis: SubmitDiagnosis = () => Effect.succeed(undefined)

const SESSION_ATTRIBUTES = { [MAPLE_NATIVE_SESSION_ID_ATTR]: "session-1" }

const build = (sessionId: string, userId: UserId) =>
buildDiagnosisCompletion(sessionId, tenantFor(userId), submitDiagnosis, makeRunUsage(), MODEL_NAME)
buildDiagnosisCompletion(
sessionId,
tenantFor(userId),
submitDiagnosis,
makeRunUsage(),
MODEL_NAME,
false,
SESSION_ATTRIBUTES,
)

const sampleSubmission = () => ({
summary: "Checkout latency doubled after the 14:00 deploy.",
suspectedCause: "Regression in the payments client connection pool",
severityAssessment: "high",
affectedScope: "checkout-api, p95 across all regions",
evidence: [{ traceIds: ["abc123def456"], note: "Pool saturation in the failing traces" }],
suggestedActions: ["Roll back the 14:00 deploy"],
confidence: "high",
})

describe("buildDiagnosisCompletion", () => {
it("gives an ordinary conversation no completion at all", () => {
Expand Down Expand Up @@ -57,4 +79,32 @@ describe("buildDiagnosisCompletion", () => {
assert.isDefined(completion?.toolkit)
assert.isFalse(completion?.autonomous)
})

/**
* The arguments ARE the report, and the engine's `execute_tool` span carries no content of its
* own: registered without `toolHandlersWithContent`, every diagnosis in Agent Sessions rendered
* as a tool call with no arguments and no result.
*/
it("records the report and the result on the tool's span", async () => {
const completion = build(INVESTIGATION_SESSION, internal)
assert.isDefined(completion)
const { spans, tracer } = makeRecordingTracer()

await Effect.runPromise(
completion!.handlers[SUBMIT_DIAGNOSIS](sampleSubmission(), {} as never).pipe(
Effect.withSpan(`execute_tool ${SUBMIT_DIAGNOSIS}`),
Effect.withTracer(tracer),
),
)

const attributes = spans[0]?.attributes
assert.include(
String(attributes?.get("gen_ai.tool.call.arguments")),
"Checkout latency doubled",
)
assert.include(String(attributes?.get("gen_ai.tool.call.result")), "Diagnosis recorded.")
assert.include(String(attributes?.get("gen_ai.tool.description")), "structured diagnosis")
// The same session identity every other tool span carries, so the call files under its turn.
assert.equal(attributes?.get(MAPLE_NATIVE_SESSION_ID_ATTR), "session-1")
})
})
7 changes: 5 additions & 2 deletions apps/ai/src/chat/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Tool, Toolkit } from "effect/unstable/ai"
import type { McpToolExecutorApi } from "../mcp/dispatcher"
import type { McpToolSurface } from "@maple/domain/mcp-manifest"
import { buildMapleToolkit, MapleToolFailure, summarizeToolFailure } from "../mcp/tools/llm-tools"
import { toolHandlersWithContent } from "../platform/genai-spans"
import type { TenantContext } from "@maple/backend/services/auth/tenant-context"

const decodeInvestigationIdOption = Schema.decodeUnknownOption(InvestigationId)
Expand Down Expand Up @@ -137,14 +138,16 @@ export const buildDiagnosisCompletion = (
modelName: string,
/** This run is the close-out: whatever it files is a partial, and lands as `inconclusive`. */
partial = false,
/** The run's agent-session identity, stamped on the tool span like every other tool's. */
sessionAttributes?: Readonly<Record<string, string>>,
) => {
const investigationId = investigationForSession(sessionId)
if (investigationId === undefined) return undefined
const toolkit = Toolkit.make(diagnosisTool)
let submitted = false
return {
toolkit,
layer: toolkit.toLayer({
...toolHandlersWithContent(toolkit, {
[SUBMIT_DIAGNOSIS]: (submission: AiTriageSubmission) =>
Effect.suspend(() => {
const { report, filled } = normalizeTriageSubmission(submission)
Expand Down Expand Up @@ -181,7 +184,7 @@ export const buildDiagnosisCompletion = (
),
),
),
}),
}, sessionAttributes),
autonomous: isAutonomousInvestigationTurn(sessionId, tenant),
submitted: () => submitted,
}
Expand Down
22 changes: 5 additions & 17 deletions apps/ai/src/mcp/tools/llm-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { McpToolExecutorApi } from "../dispatcher"
import type { McpToolSurface } from "@maple/domain/mcp-manifest"
import { mapleToolCatalogFor, toInputSchema } from "./registry"
import { truncateToolOutput } from "./tool-output"
import { withToolCallContent } from "../../platform/genai-spans"
import { toolHandlersWithContent } from "../../platform/genai-spans"
import type { TenantContext } from "@maple/backend/services/auth/tenant-context"

/**
Expand Down Expand Up @@ -187,27 +187,15 @@ export const buildMapleToolkit = (
}
return dispatch(params)
}
return [
definition.name,
(params: unknown) =>
withToolCallContent(
Effect.suspend(() => handle(params)),
{
description: describe(definition, gated),
params,
...(options.sessionAttributes === undefined
? undefined
: { sessionAttributes: options.sessionAttributes }),
},
),
]
return [definition.name, (params: unknown) => Effect.suspend(() => handle(params))]
// A dynamic tool's shape is known only at runtime, so the model's arguments arrive
// unparsed and the handler parses them.
// oxlint-disable-next-line anti-slop/no-unknown-parameters
}) as ReadonlyArray<readonly [string, (params: unknown) => Effect.Effect<string, MapleToolFailure>]>,
)
// Registered as one map, so a tool added to the catalogue cannot arrive without its span content.
// `handlers` is exposed alongside the layer because a caller that merges this toolkit with one
// of its own must build a single handler map: two partial layers would each be missing the
// other's tools.
return { toolkit, handlers, layer: toolkit.toLayer(handlers) }
// other's tools. It is the wrapped map for the same reason.
return { toolkit, ...toolHandlersWithContent(toolkit, handlers, options.sessionAttributes) }
}
65 changes: 61 additions & 4 deletions apps/ai/src/platform/genai-spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type * as LanguageModel from "effect/unstable/ai/LanguageModel"
import type * as Prompt from "effect/unstable/ai/Prompt"
import * as Telemetry from "effect/unstable/ai/Telemetry"
import * as Tool from "effect/unstable/ai/Tool"
import type * as Toolkit from "effect/unstable/ai/Toolkit"

/**
* Per-attribute size budgets, in JSON characters.
Expand Down Expand Up @@ -528,29 +529,85 @@ const annotateExecuteToolSpan = Effect.fnUntraced(function* (attributes: Readonl
for (const [key, value] of Object.entries(attributes)) span.value.attribute(key, value)
})

/**
* A failure as the span should read it: the message a tool failure carries, or the value itself.
*
* Unconstrained because this runs over every handler a toolkit registers, and a handler may also
* fail with the engine's own `AiError` — which carries a message too, and a value that does not is
* still better rendered than dropped.
*/
const failureText = (error: unknown): string =>
Predicate.hasProperty(error, "message") && Predicate.isString(error.message)
? error.message
: String(error)

/**
* Record a tool call's description, arguments and result — or failure — on its `execute_tool` span.
*
* `sessionAttributes` is the identity the model-call spans carry (`agentSessionSpanAttributes`). The
* engine stamps only its thread id on a tool span, so without it the session view has nothing that
* puts a tool call in the turn of the model call that asked for it.
*/
export const withToolCallContent = <A, E extends { readonly message: string }, R>(
export const withToolCallContent = <A, E, R>(
handler: Effect.Effect<A, E, R>,
call: {
readonly description: string
readonly description?: string | undefined
readonly params: unknown
readonly sessionAttributes?: Readonly<Record<string, string>>
},
): Effect.Effect<A, E, R> =>
annotateExecuteToolSpan({
...call.sessionAttributes,
"gen_ai.tool.description": call.description,
...(call.description === undefined || call.description === ""
? undefined
: { "gen_ai.tool.description": call.description }),
"gen_ai.tool.call.arguments": toolCallJson(call.params),
}).pipe(
Effect.andThen(handler),
Effect.tap((result) => annotateExecuteToolSpan({ "gen_ai.tool.call.result": toolCallJson(result) })),
Effect.tapError((error) =>
annotateExecuteToolSpan({ "gen_ai.tool.call.result": toolCallJson(error.message) }),
annotateExecuteToolSpan({ "gen_ai.tool.call.result": toolCallJson(failureText(error)) }),
),
)

/**
* A toolkit's handler map and its layer, with every handler recording its own call content.
*
* The one seam Maple has for this: effect-agent's `execute_tool` span is content-free, so a handler
* registered straight through `toolkit.toLayer` produces a tool call Agent Sessions renders with no
* arguments and no result. `submit_diagnosis` — whose arguments ARE the diagnosis report — shipped
* that way. Wrapping is a whole-map operation rather than something each handler remembers, the
* layer is built here rather than by the caller so there is no unwrapped map to register, and
* `maple/no-raw-tool-layer` keeps `toLayer` out of every other module.
*
* The description comes off the toolkit rather than the caller, so the span records what the model
* was actually given.
*/
export const toolHandlersWithContent = <
Tools extends Record<string, Tool.Any>,
Handlers extends Toolkit.HandlersFrom<Tools>,
>(
toolkit: Toolkit.Toolkit<Tools>,
handlers: Handlers,
sessionAttributes?: Readonly<Record<string, string>>,
) => {
const entries = Object.entries(handlers) as ReadonlyArray<
readonly [string, (params: never, context: never) => Effect.Effect<unknown, unknown, unknown>]
>
const described = Object.fromEntries(
entries.map(([name, handler]) => [
name,
(params: never, context: never) =>
withToolCallContent(
Effect.suspend(() => handler(params, context)),
{
description: toolkit.tools[name]?.description,
params,
...(sessionAttributes === undefined ? undefined : { sessionAttributes }),
},
),
]),
) as Handlers
// oxlint-disable-next-line maple/no-raw-tool-layer -- the one place that may: the map is wrapped
return { handlers: described, layer: toolkit.toLayer(described) }
}
34 changes: 34 additions & 0 deletions scripts/oxlint-plugins/maple.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
* the 26 production sites at the time it landed was a legitimate defect, so it
* cleans nothing up — it exists so the next one has to argue for itself.
*
* `no-raw-tool-layer` guards the tool-span seam: a handler map registered
* straight through `toolkit.toLayer` produces `execute_tool` spans with no
* arguments and no result, which is invisible until someone reads a session.
*
* `no-record-string-any` exists as its own rule (rather than leaning on
* `typescript/no-explicit-any`, which flags the inner `any` anyway) so the worst
* offender — an open key set whose values are also unchecked — can sit at `error`
Expand Down Expand Up @@ -262,11 +266,41 @@ const noEffectDie = {
},
}

const NO_RAW_TOOL_LAYER_MESSAGE =
"Do not register agent tool handlers with `toolkit.toLayer` directly. effect-agent's `execute_tool` span is content-free, so a raw registration renders in Agent Sessions as a tool call with no arguments and no result — which is how `submit_diagnosis` shipped for months. Use `toolHandlersWithContent(toolkit, handlers)` from `apps/ai/src/platform/genai-spans.ts`, which returns both the wrapped handler map and its layer."

/**
* `<toolkit>.toLayer(...)`, which is the Effect AI `Toolkit` registration seam.
*
* The name is distinctive enough to match on its own: the only `toLayer` in the
* repository is Toolkit's. `genai-spans.ts` owns the one permitted call and
* disables the rule on that line.
*/
const noRawToolLayer = {
meta: {
type: "problem",
docs: {
description: "Disallow registering tool handlers without their gen-AI span content.",
},
messages: { noRawToolLayer: NO_RAW_TOOL_LAYER_MESSAGE },
},
create(context) {
return {
MemberExpression(node) {
if (node.computed) return
if (node.property.type !== "Identifier" || node.property.name !== "toLayer") return
context.report({ node, messageId: "noRawToolLayer" })
},
}
},
}

export default {
meta: { name: "maple" },
rules: {
"no-effect-die": noEffectDie,
"no-ordie-compiled-query": noOrDieCompiledQuery,
"no-raw-tool-layer": noRawToolLayer,
"no-react-use-effect": noReactUseEffect,
"no-record-string-any": noRecordStringAny,
"no-try-catch": noTryCatch,
Expand Down
Loading