Lifecycle Hooks
RAG, guardrails, redaction, model fallback, and tool policy.
This recipe shows several lifecycle helpers composed into one agent.
// server/lifecycle.ts
import type { AgentLifecycle } from "@pai/core";
import type { LanguageModel } from "ai";
import { z } from "zod";
import type { AssistantContext } from "./agent";
import { redactTextDeltas } from "./stream-transforms";
function latestUserText(
messages: readonly { role: string; parts: readonly { type: string; text?: string }[] }[],
) {
const message = [...messages].reverse().find((item) => item.role === "user");
return message?.parts
.filter((part) => part.type === "text")
.map((part) => part.text ?? "")
.join("\n")
.trim() || null;
}
export function withRag(): AgentLifecycle<AssistantContext> {
return {
name: "workspace-rag",
async prepareModelStep({ input, runtimeContext }) {
const query = latestUserText(input.messages);
if (!query) return;
const docs = await runtimeContext.search.similar(query);
if (docs.length === 0) return;
return {
action: "continue",
patch: {
appendModelMessages: [
{
role: "system",
parts: [{ type: "text", text: `Workspace context:\n${docs.join("\n\n")}` }],
},
],
},
};
},
};
}
export const blockUnsafeRequests: AgentLifecycle<AssistantContext> = {
name: "block-unsafe-requests",
async prepareRun({ latestUserMessage, runtimeContext }) {
if (!latestUserMessage) return;
if (!(await runtimeContext.policy.isUnsafe([latestUserMessage]))) return;
return {
action: "respond",
outcome: "blocked",
reason: "unsafe_request",
response: {
parts: [{ type: "text", text: "I cannot help with that request." }],
},
};
},
};
export const redactSecrets: AgentLifecycle<AssistantContext> = {
name: "redact-secrets",
transformStepStream({ stream }) {
return stream.pipeThrough(
redactTextDeltas({
patterns: [/sk-[A-Za-z0-9_-]+/g],
replacement: "[redacted secret]",
}),
);
},
};
export const blockUnsafeOutput: AgentLifecycle<AssistantContext> = {
name: "block-unsafe-output",
transformStepStream({ stream, abort }) {
return stream.pipeThrough(
inspectTextDeltas({
async onText(text) {
if (await isUnsafeOutput(text)) {
await abort({
reason: "unsafe_output",
outcome: "blocked",
replacement: {
parts: [{ type: "text", text: "I cannot continue with that output." }],
},
});
}
},
}),
);
},
};
export function withModelFallback(
fallbackModel: LanguageModel,
): AgentLifecycle<AssistantContext> {
return {
name: "fallback-model",
aroundModelCall(input, next) {
if (modelHealth.isUnavailable(input.model)) {
return next({ model: fallbackModel });
}
return next();
},
};
}
export const emailPolicy: AgentLifecycle<AssistantContext> = {
name: "email-policy",
beforeToolCall({ tool, input, runtimeContext }) {
if (tool.name !== "sendEmail") return;
const parsed = z.object({ to: z.string().email() }).parse(input);
if (!runtimeContext.policy.canEmail(parsed.to)) {
return {
action: "reject",
message: `Email to ${parsed.to} is not allowed.`,
};
}
},
};Compose those helpers on the agent definition:
// server/agent.ts
import { composeLifecycle, defineAgent, type InferAgentContract } from "@pai/core";
import {
blockUnsafeRequests,
blockUnsafeOutput,
emailPolicy,
redactSecrets,
withModelFallback,
withRag,
} from "./lifecycle";
import { fallbackModel } from "./models";
export const agent = defineAgent({
name: "report",
model: ({ runtimeContext }) => runtimeContext.models.primary,
instructions: ({ runtimeContext }) =>
`Draft concise reports for ${runtimeContext.workspace.name}.`,
runtimeContext: buildAssistantContext,
tools: {
sendEmail,
updateReport,
},
lifecycle: composeLifecycle(
blockUnsafeRequests,
withRag(),
withModelFallback(fallbackModel),
emailPolicy,
blockUnsafeOutput,
redactSecrets,
),
});
export type AssistantAgentContract = InferAgentContract<typeof agent>;Test canonical behavior against refreshed state:
import { createTestRuntime, mockModel, textResponse } from "@pai/test-utils";
import { expect, it } from "vitest";
import { agent } from "../server/agent";
it("redacts secrets before persistence", async () => {
const runtime = createTestRuntime({
agent,
model: mockModel([textResponse("Token: sk-test-secret")]),
});
try {
const pai = runtime.client({ identity });
const thread = pai.thread(pai.newThreadId());
await (await thread.send("Show token")).waitUntilIdle();
await thread.refresh();
const text = thread
.getState()
.messages.flatMap((message) => message.parts)
.filter((part) => part.type === "text")
.map((part) => part.text);
expect(text).toContain("Token: [redacted secret]");
} finally {
await runtime.close();
}
});Use lifecycle hooks for behavior that belongs to the agent. Use runtime telemetry for logging-only observability, and use tool suspend() when execution must wait for human input.
aroundModelCall receives a complete effective request, but next() accepts a
sparse patch. A patch supplying model clears inherited model settings,
provider options, provider-native tools, and tool choice before applying
fallback-specific values. Registered PAI tools remain available. Do not spread
input into that patch. Also note that next() returns once the stream is
available; handling provider failures raised during stream consumption requires
a stream-aware fallback policy.