Lifecycle
Understanding the request lifecycle and hook system in BaseAgent.
Request Lifecycle
Lifecycle Hooks
Available Hooks
- on_connection: Request initialized
- before_llm_call: Before each LLM call (can modify messages and tools in context)
- after_llm_call: After each LLM call (can inspect the response)
- before_toolcall: Before tool execution
- after_toolcall: After tool execution
- on_message: After the agentic loop completes (full conversation available)
- on_chunk: Each streaming chunk
- finalize_connection: Request complete
finalize_connectionruns for cleanup even when a prior hook raises a structured error (for example, a 402 payment/auth error). Implement finalize hooks to be idempotent and safe when required context (like a payment token) is missing.
Hook Registration
import { Skill, hook } from 'webagents';
import type { HookData, Context } from 'webagents';
class AnalyticsSkill extends Skill {
readonly name = 'analytics';
@hook({ lifecycle: 'on_connection', priority: 10 })
async trackRequest(data: HookData, ctx: Context) {
console.log(`New request: ${ctx.metadata.completion_id}`);
return data;
}
@hook({ lifecycle: 'on_message', priority: 20 })
async analyzeMessage(data: HookData, ctx: Context) {
const last = data.messages?.at(-1);
console.log(`Message role: ${last?.role}`);
return data;
}
@hook({ lifecycle: 'on_chunk', priority: 30 })
async monitorStreaming(data: HookData, ctx: Context) {
const chunkSize = (data.content ?? '').length;
console.log(`Chunk size: ${chunkSize}`);
return data;
}
}Hook Priority
Hooks execute in priority order (lower numbers first):
import { Skill, hook } from 'webagents';
class SecuritySkill extends Skill {
readonly name = 'security';
@hook({ lifecycle: 'before_toolcall', priority: 1 })
async validateSecurity(data, ctx) {
const toolName = data.tool_call?.function?.name;
if (this.isDangerous(toolName)) {
throw new Error(`Tool blocked: ${toolName}`);
}
return data;
}
private isDangerous(name?: string) { return name === 'rm_rf_root'; }
}
class LoggingSkill extends Skill {
readonly name = 'logging';
@hook({ lifecycle: 'before_toolcall', priority: 10 })
async logToolUsage(data, ctx) {
this.logTool(data.tool_call);
return data;
}
private logTool(_: unknown) {}
}Context During Lifecycle
Connection Context
@hook({ lifecycle: 'on_connection' })
async onConnect(data: HookData, ctx: Context) {
// Available on data / ctx:
// data.messages — Message[]
// data.stream — boolean
// ctx.auth — AuthInfo (peer_user_id, scopes)
// ctx.metadata — completion_id, model, agent_name
// ctx.session — SessionState
return data;
}Message Context
@hook({ lifecycle: 'on_message' })
async onMsg(data: HookData, ctx: Context) {
const current = data.messages!.at(-1)!;
const role = current.role;
const content = current.content;
return data;
}Tool Context
@hook({ lifecycle: 'before_toolcall' })
async beforeTool(data: HookData, ctx: Context) {
// data.tool_call — { id, function: { name, arguments } }
return data;
}
@hook({ lifecycle: 'after_toolcall' })
async afterTool(data: HookData, ctx: Context) {
// data.tool_result — string
return data;
}Streaming Context
@hook({ lifecycle: 'on_chunk' })
async onChunk(data: HookData, ctx: Context) {
// data.chunk — OpenAI-format streaming chunk
// data.content — string (current chunk content)
// data.chunk_index — number
// data.full_content — string (accumulated)
return data;
}Context Compaction
An agent keeps a conversation within its model's context by compacting it, the same way in both SDKs (the policy and the steps are in Configuration). It happens in three places:
- Between turns, by the host. The chat measures the conversation before
each message and calls
compactIfNeeded(Pythoncompact_if_needed), then keeps the result as the conversation it sends from then on. Another host that keeps its own history does the same. - When asked.
compactcompacts now, whatever the size: everything before the latest exchange becomes one summary. This is the chat's/compact. - Inside one long turn. After
before_llm_call, a run whose messages have passed the policy'shardcompacts them by itself, leaving the turn in progress whole, so a long tool loop does not overflow the context. This is a safety stop; the host's compaction between turns is the usual path.
Neither method changes the list it is given; the result carries the new messages.
const outcome = await agent.compactIfNeeded(messages, tokensOfNextMessage);
if (outcome.stage !== 'none') messages = outcome.messages;
const forced = await agent.compact(messages, { focus: 'the budget decisions' });
// forced.stage: 'summarized' | 'cleared' | 'dropped' | 'none'
// forced.before / forced.after: estimated tokens; forced.window: the context window
agent.compactionPolicy = { ...agent.compactionPolicy, at: 0.7 };The last outcome is kept on agent.lastCompaction (Python
agent.last_compaction). A skill that wants to know defines onCompaction
(Python on_compaction), called once per compaction with the outcome and the
run's context; the memory skill uses it to keep each summary as an episode.
class JournalSkill extends Skill {
async onCompaction(outcome: Compaction, context?: Context): Promise<void> {
if (outcome.summary) await this.saveSummary(outcome.summary);
}
}Practical Examples
Request Logging
import { Skill, hook } from 'webagents';
class RequestLogger extends Skill {
readonly name = 'request-logger';
private startTime = 0;
private requestId = '';
@hook({ lifecycle: 'on_connection' })
async startLogging(data, ctx) {
this.startTime = Date.now();
this.requestId = String(ctx.metadata.completion_id ?? '');
await this.logRequestStart(data, ctx);
return data;
}
@hook({ lifecycle: 'finalize_connection' })
async endLogging(data, ctx) {
const duration = (Date.now() - this.startTime) / 1000;
await this.logRequestComplete(this.requestId, duration, data.usage);
return data;
}
private async logRequestStart(_d: unknown, _c: unknown) {}
private async logRequestComplete(_id: string, _d: number, _u: unknown) {}
}Content Filtering
import { Skill, hook } from 'webagents';
class ContentFilter extends Skill {
readonly name = 'content-filter';
@hook({ lifecycle: 'on_message', priority: 5 })
async filterInput(data, ctx) {
const last = data.messages?.at(-1);
if (last?.role === 'user') {
last.content = this.filterContent(String(last.content ?? ''));
}
return data;
}
@hook({ lifecycle: 'on_chunk', priority: 5 })
async filterOutput(data, ctx) {
if (this.isInappropriate(data.content ?? '')) {
data.chunk.choices[0].delta.content = '[filtered]';
}
return data;
}
private filterContent(s: string) { return s; }
private isInappropriate(_: string) { return false; }
}Performance Monitoring
import { Skill, hook } from 'webagents';
class PerformanceMonitor extends Skill {
readonly name = 'performance-monitor';
private metrics = new Map<string, { start: number }>();
@hook({ lifecycle: 'before_toolcall' })
async startTimer(data, ctx) {
const toolId = String(data.tool_id);
this.metrics.set(toolId, { start: Date.now() });
return data;
}
@hook({ lifecycle: 'after_toolcall' })
async recordDuration(data, ctx) {
const toolId = String(data.tool_id);
const start = this.metrics.get(toolId)?.start ?? Date.now();
const duration = (Date.now() - start) / 1000;
await this.recordMetric('tool_duration', duration, {
tool: data.tool_call?.function?.name,
});
return data;
}
private async recordMetric(_n: string, _v: number, _t: object) {}
}Best Practices
- Use Priorities: Order hooks appropriately.
- Return Context: Always return modified context (or
datain TypeScript). - Handle Errors: Gracefully handle exceptions; remember
finalize_connectionstill runs. - Minimize Overhead: Keep hooks lightweight.
- Thread Safety: Use context vars / immutable copies for shared state.