fix(cli): remove side-effect from agent:end state updater (#133) (#147)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #147.
This commit is contained in:
2026-03-15 19:09:13 +00:00
committed by jason.woltje
parent c4850fe6c1
commit 76abf11eba
4 changed files with 293 additions and 31 deletions

View File

@@ -1,6 +1,7 @@
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
SessionManager,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
@@ -27,6 +28,27 @@ import type { SessionInfoDto } from './session.dto.js';
export interface AgentSessionOptions {
provider?: string;
modelId?: string;
/**
* Sandbox working directory for the session.
* File, git, and shell tools will be restricted to this directory.
* Falls back to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
*/
sandboxDir?: string;
/**
* Platform-level system prompt for this session.
* Merged with skill prompt additions (platform prompt first, then skills).
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
*/
systemPrompt?: string;
/**
* Explicit allowlist of tool names available in this session.
* When set, only listed tools are registered with the agent.
* When omitted for non-admin users, falls back to AGENT_USER_TOOLS env var.
* Admins (isAdmin=true) always receive the full tool set unless explicitly restricted.
*/
allowedTools?: string[];
/** Whether the requesting user has admin privileges. Controls default tool access. */
isAdmin?: boolean;
}
export interface AgentSession {
@@ -41,6 +63,10 @@ export interface AgentSession {
channels: Set<string>;
/** System prompt additions injected from enabled prompt-type skills. */
skillPromptAdditions: string[];
/** Resolved sandbox directory for this session. */
sandboxDir: string;
/** Tool names available in this session, or null when all tools are available. */
allowedTools: string[] | null;
}
@Injectable()
@@ -49,8 +75,6 @@ export class AgentService implements OnModuleDestroy {
private readonly sessions = new Map<string, AgentSession>();
private readonly creating = new Map<string, Promise<AgentSession>>();
private readonly customTools: ToolDefinition[];
constructor(
@Inject(ProviderService) private readonly providerService: ProviderService,
@Inject(BRAIN) private readonly brain: Brain,
@@ -59,21 +83,49 @@ export class AgentService implements OnModuleDestroy {
@Inject(CoordService) private readonly coordService: CoordService,
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
) {
const fileBaseDir = process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
const gitDefaultCwd = process.env['AGENT_GIT_CWD'] ?? process.cwd();
const shellDefaultCwd = process.env['AGENT_SHELL_CWD'] ?? process.cwd();
) {}
this.customTools = [
...createBrainTools(brain),
...createCoordTools(coordService),
...createMemoryTools(memory, embeddingService.available ? embeddingService : null),
...createFileTools(fileBaseDir),
...createGitTools(gitDefaultCwd),
...createShellTools(shellDefaultCwd),
/**
* Build the full set of custom tools scoped to the given sandbox directory.
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
* tools receive the resolved sandboxDir so they operate within the sandbox.
*/
private buildToolsForSandbox(sandboxDir: string): ToolDefinition[] {
return [
...createBrainTools(this.brain),
...createCoordTools(this.coordService),
...createMemoryTools(
this.memory,
this.embeddingService.available ? this.embeddingService : null,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
...createShellTools(sandboxDir),
...createWebTools(),
];
this.logger.log(`Registered ${this.customTools.length} custom tools`);
}
/**
* Resolve the tool allowlist for a session.
* - Admin users: all tools unless an explicit allowedTools list is passed.
* - Regular users: use allowedTools if provided, otherwise parse AGENT_USER_TOOLS env var.
* Returns null when all tools should be available.
*/
private resolveAllowedTools(isAdmin: boolean, allowedTools?: string[]): string[] | null {
if (allowedTools !== undefined) {
return allowedTools.length === 0 ? [] : allowedTools;
}
if (isAdmin) {
return null; // admins get everything
}
const envTools = process.env['AGENT_USER_TOOLS'];
if (!envTools) {
return null; // no restriction configured
}
return envTools
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
@@ -98,8 +150,15 @@ export class AgentService implements OnModuleDestroy {
const providerName = model?.provider ?? 'default';
const modelId = model?.id ?? 'default';
// Resolve sandbox directory: option > env var > process.cwd()
const sandboxDir =
options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
// Resolve allowed tool set
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
this.logger.log(
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`,
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
);
// Load skill tools from the catalog
@@ -114,21 +173,53 @@ export class AgentService implements OnModuleDestroy {
);
}
// Combine static tools with dynamically discovered MCP client tools
// Build per-session tools scoped to the sandbox directory
const sandboxTools = this.buildToolsForSandbox(sandboxDir);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
const allCustomTools = [...this.customTools, ...skillMetaTools, ...mcpTools];
let allCustomTools = [...sandboxTools, ...skillMetaTools, ...mcpTools];
if (mcpTools.length > 0) {
this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`);
}
// Filter tools by allowlist when a restriction is in effect
if (allowedTools !== null) {
const allowedSet = new Set(allowedTools);
const before = allCustomTools.length;
allCustomTools = allCustomTools.filter((t) => allowedSet.has(t.name));
this.logger.log(
`Tool restriction applied: ${allCustomTools.length}/${before} tools allowed for session ${sessionId}`,
);
}
// Build system prompt: platform prompt + skill additions appended
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
const appendSystemPrompt =
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
// Construct a resource loader that injects the configured system prompt
const resourceLoader = new DefaultResourceLoader({
cwd: sandboxDir,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
systemPrompt: platformPrompt,
appendSystemPrompt: appendSystemPrompt,
});
await resourceLoader.reload();
let piSession: PiAgentSession;
try {
const result = await createAgentSession({
sessionManager: SessionManager.inMemory(),
modelRegistry: this.providerService.getRegistry(),
model: model ?? undefined,
cwd: sandboxDir,
tools: [],
customTools: allCustomTools,
resourceLoader,
});
piSession = result.session;
} catch (err) {
@@ -162,6 +253,8 @@ export class AgentService implements OnModuleDestroy {
promptCount: 0,
channels: new Set(),
skillPromptAdditions: promptAdditions,
sandboxDir,
allowedTools,
};
this.sessions.set(sessionId, session);