feat: per-agent workingDir config (#412)
This commit is contained in:
@@ -234,9 +234,10 @@ Each entry in `agents:` accepts:
|
||||
| `id` | string | No | Use existing agent ID (skips creation) |
|
||||
| `displayName` | string | No | Prefix outbound messages (e.g. `"💜 Signo"`) |
|
||||
| `model` | string | No | Model for agent creation |
|
||||
| `workingDir` | string | No | Working directory for this agent's SDK sessions (overrides global `LETTABOT_WORKING_DIR`) |
|
||||
| `conversations` | object | No | Conversation routing (mode, heartbeat, perChannel overrides) |
|
||||
| `channels` | object | No | Channel configs (same schema as top-level `channels:`). At least one agent must have channels. |
|
||||
| `features` | object | No | Per-agent features (cron, heartbeat, memfs, maxToolCalls) |
|
||||
| `features` | object | No | Per-agent features (cron, heartbeat, memfs, maxToolCalls, allowedTools, etc.) |
|
||||
| `polling` | object | No | Per-agent polling config (Gmail, etc.) |
|
||||
| `integrations` | object | No | Per-agent integrations (Google, etc.) |
|
||||
|
||||
@@ -296,7 +297,6 @@ The `server:` (including `server.api:`), `transcription:`, and `attachments:` se
|
||||
|
||||
- Two agents cannot share the same channel type without ambiguous API routing ([#219](https://github.com/letta-ai/lettabot/issues/219))
|
||||
- WhatsApp/Signal session paths are not yet agent-scoped ([#220](https://github.com/letta-ai/lettabot/issues/220))
|
||||
- Heartbeat prompt and target are not yet configurable per-agent ([#221](https://github.com/letta-ai/lettabot/issues/221))
|
||||
|
||||
## Channel Configuration
|
||||
|
||||
@@ -598,6 +598,55 @@ Notes:
|
||||
- Reasoning display uses plain bold/italic markdown for better cross-channel compatibility (including Signal).
|
||||
- Display messages are informational; they do not replace the assistant response. Normal retry/error handling still applies if no assistant reply is produced.
|
||||
|
||||
### Tool Access Control
|
||||
|
||||
Control which tools the agent can use. Useful for restricting public-facing agents to read-only operations while giving personal agents full access.
|
||||
|
||||
```yaml
|
||||
# Global defaults (apply to all agents unless overridden)
|
||||
features:
|
||||
allowedTools: [Bash, Read, Edit, Write, Glob, Grep, Task, web_search, conversation_search]
|
||||
disallowedTools: [EnterPlanMode, ExitPlanMode]
|
||||
```
|
||||
|
||||
Per-agent override:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
- name: personal-bot
|
||||
# Inherits global allowedTools (includes Bash, Edit, Write)
|
||||
|
||||
- name: public-bot
|
||||
features:
|
||||
allowedTools: [Read, Glob, Grep, web_search, conversation_search] # Read-only
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `features.allowedTools` | string[] | `[Bash, Read, Edit, Write, Glob, Grep, Task, web_search, conversation_search]` | Tools the agent is allowed to use |
|
||||
| `features.disallowedTools` | string[] | `[EnterPlanMode, ExitPlanMode]` | Tools explicitly blocked |
|
||||
|
||||
**Precedence:** Per-agent YAML > global YAML `features` > `ALLOWED_TOOLS` / `DISALLOWED_TOOLS` env var > hardcoded default.
|
||||
|
||||
The `manage_todo` tool is always included regardless of configuration.
|
||||
|
||||
### Per-Agent Working Directory
|
||||
|
||||
Each agent can have its own working directory, which sets the `cwd` for SDK sessions, heartbeat, and polling services:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
- name: personal-bot
|
||||
workingDir: ~/lettabot
|
||||
|
||||
- name: central-bot
|
||||
workingDir: ~/central
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `workingDir` | string | `LETTABOT_WORKING_DIR` env var or process cwd | Working directory for this agent's sessions |
|
||||
|
||||
### No-Reply (Opt-Out)
|
||||
|
||||
The agent can choose not to respond to a message by sending exactly:
|
||||
@@ -839,6 +888,9 @@ Reference:
|
||||
| `LOG_LEVEL` | `server.logLevel` (fatal/error/warn/info/debug/trace). Overrides config. |
|
||||
| `LETTABOT_LOG_LEVEL` | Alias for `LOG_LEVEL` |
|
||||
| `LOG_FORMAT` | Set to `json` for structured JSON output (recommended for Railway/Docker) |
|
||||
| `ALLOWED_TOOLS` | `features.allowedTools` (comma-separated list) |
|
||||
| `DISALLOWED_TOOLS` | `features.disallowedTools` (comma-separated list) |
|
||||
| `LETTABOT_WORKING_DIR` | Agent working directory (overridden by per-agent `workingDir`) |
|
||||
| `TTS_PROVIDER` | TTS backend: `elevenlabs` (default) or `openai` |
|
||||
| `ELEVENLABS_API_KEY` | API key for ElevenLabs TTS |
|
||||
| `ELEVENLABS_VOICE_ID` | ElevenLabs voice ID (default: `21m00Tcm4TlvDq8ikWAM` / Rachel) |
|
||||
|
||||
@@ -20,6 +20,8 @@ agents:
|
||||
# Note: model is configured on the Letta agent server-side.
|
||||
# Select a model during `lettabot onboard` or change it with `lettabot model set <handle>`.
|
||||
|
||||
# workingDir: ~/my-project # Per-agent working directory (overrides global WORKING_DIR)
|
||||
|
||||
# Per-agent tool access (overrides global features.allowedTools / features.disallowedTools)
|
||||
# features:
|
||||
# allowedTools: [Read, Glob, Grep, web_search, conversation_search] # Read-only agent (no Bash/Edit/Write)
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface AgentConfig {
|
||||
displayName?: string;
|
||||
/** Model for initial agent creation */
|
||||
model?: string;
|
||||
/** Working directory for this agent's SDK sessions (overrides global) */
|
||||
workingDir?: string;
|
||||
/** Channels this agent connects to */
|
||||
channels: {
|
||||
telegram?: TelegramConfig;
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@@ -23,7 +23,7 @@ import {
|
||||
serverModeLabel,
|
||||
} from './config/index.js';
|
||||
import { isLettaApiUrl } from './utils/server.js';
|
||||
import { getDataDir, getWorkingDir, hasRailwayVolume } from './utils/paths.js';
|
||||
import { getDataDir, getWorkingDir, hasRailwayVolume, resolveWorkingDirPath } from './utils/paths.js';
|
||||
import { parseCsvList, parseNonNegativeNumber } from './utils/parse.js';
|
||||
import { sleep } from './utils/time.js';
|
||||
import { createLogger, setLogLevel } from './logger.js';
|
||||
@@ -576,8 +576,11 @@ async function main() {
|
||||
const resolvedMemfs = agentConfig.features?.memfs ?? (process.env.LETTABOT_MEMFS === 'true' ? true : false);
|
||||
|
||||
// Create LettaBot for this agent
|
||||
const resolvedWorkingDir = agentConfig.workingDir
|
||||
? resolveWorkingDirPath(agentConfig.workingDir)
|
||||
: globalConfig.workingDir;
|
||||
const bot = new LettaBot({
|
||||
workingDir: globalConfig.workingDir,
|
||||
workingDir: resolvedWorkingDir,
|
||||
agentName: agentConfig.name,
|
||||
allowedTools: ensureRequiredTools(agentConfig.features?.allowedTools ?? globalConfig.allowedTools),
|
||||
disallowedTools: agentConfig.features?.disallowedTools ?? globalConfig.disallowedTools,
|
||||
@@ -689,7 +692,7 @@ async function main() {
|
||||
agentKey: agentConfig.name,
|
||||
prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT,
|
||||
promptFile: heartbeatConfig?.promptFile,
|
||||
workingDir: globalConfig.workingDir,
|
||||
workingDir: resolvedWorkingDir,
|
||||
target: parseHeartbeatTarget(heartbeatConfig?.target) || parseHeartbeatTarget(process.env.HEARTBEAT_TARGET),
|
||||
});
|
||||
if (heartbeatConfig?.enabled) {
|
||||
@@ -732,7 +735,7 @@ async function main() {
|
||||
if (pollConfig.enabled && pollConfig.gmail.enabled && pollConfig.gmail.accounts.length > 0) {
|
||||
const pollingService = new PollingService(bot, {
|
||||
intervalMs: pollConfig.intervalMs,
|
||||
workingDir: globalConfig.workingDir,
|
||||
workingDir: resolvedWorkingDir,
|
||||
gmail: pollConfig.gmail,
|
||||
});
|
||||
pollingService.start();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
getCronDataDir,
|
||||
getCronLogPath,
|
||||
getCronStorePath,
|
||||
getLegacyCronStorePath,
|
||||
getWorkingDir,
|
||||
resolveWorkingDirPath,
|
||||
} from './paths.js';
|
||||
|
||||
const TEST_ENV_KEYS = [
|
||||
@@ -70,3 +73,32 @@ describe('cron path resolution', () => {
|
||||
expect(getLegacyCronStorePath()).toBe(resolve(process.cwd(), 'cron-jobs.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('working directory path resolution', () => {
|
||||
beforeEach(() => {
|
||||
clearPathEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearPathEnv();
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('expands ~ in configured WORKING_DIR', () => {
|
||||
process.env.WORKING_DIR = '~/lettabot-work';
|
||||
expect(getWorkingDir()).toBe(resolve(homedir(), 'lettabot-work'));
|
||||
});
|
||||
|
||||
it('resolves relative WORKING_DIR to absolute path', () => {
|
||||
process.env.WORKING_DIR = 'tmp/lettabot';
|
||||
expect(getWorkingDir()).toBe(resolve('tmp/lettabot'));
|
||||
});
|
||||
|
||||
it('expands ~ for per-agent workingDir values', () => {
|
||||
expect(resolveWorkingDirPath('~/agent-work')).toBe(resolve(homedir(), 'agent-work'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,22 @@
|
||||
* 3. process.cwd() (default - local development)
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Resolve a working directory path into an absolute path.
|
||||
* Supports `~` for home directory and normalizes relative paths.
|
||||
*/
|
||||
export function resolveWorkingDirPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return '/tmp/lettabot';
|
||||
if (trimmed === '~') return homedir();
|
||||
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||
return resolve(join(homedir(), trimmed.slice(2)));
|
||||
}
|
||||
return resolve(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory for persistent data storage.
|
||||
@@ -42,7 +57,7 @@ export function getDataDir(): string {
|
||||
export function getWorkingDir(): string {
|
||||
// Explicit WORKING_DIR always wins
|
||||
if (process.env.WORKING_DIR) {
|
||||
return process.env.WORKING_DIR;
|
||||
return resolveWorkingDirPath(process.env.WORKING_DIR);
|
||||
}
|
||||
|
||||
// On Railway with volume, use volume/data subdirectory
|
||||
|
||||
Reference in New Issue
Block a user