mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge pull request #1848 from qwibitai/refactor/pr6-agent-to-agent
refactor(modules): extract agent-to-agent as registry-based module
This commit is contained in:
@@ -31,7 +31,6 @@ import {
|
||||
markContainerRunning,
|
||||
markContainerStopped,
|
||||
sessionDir,
|
||||
writeDestinations,
|
||||
writeSessionRouting,
|
||||
} from './session-manager.js';
|
||||
import type { AgentGroup, Session } from './types.js';
|
||||
@@ -90,8 +89,12 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
}
|
||||
|
||||
// Refresh the destination map and default reply routing so any admin
|
||||
// changes take effect on wake.
|
||||
writeDestinations(agentGroup.id, session.id);
|
||||
// changes take effect on wake. Destinations come from the agent-to-agent
|
||||
// module — skip when the module isn't installed (table absent).
|
||||
if (hasTable(getDb(), 'agent_destinations')) {
|
||||
const { writeDestinations } = await import('./modules/agent-to-agent/write-destinations.js');
|
||||
writeDestinations(agentGroup.id, session.id);
|
||||
}
|
||||
writeSessionRouting(agentGroup.id, session.id);
|
||||
|
||||
// Resolve the effective provider + any host-side contribution it declares
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('messaging group agents', () => {
|
||||
});
|
||||
|
||||
it('auto-creates an agent_destinations row for the wiring', async () => {
|
||||
const { getDestinationByTarget, getDestinations } = await import('./agent-destinations.js');
|
||||
const { getDestinationByTarget, getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||
createMessagingGroupAgent(mga());
|
||||
|
||||
const dest = getDestinationByTarget('ag-1', 'channel', 'mg-1');
|
||||
@@ -240,7 +240,7 @@ describe('messaging group agents', () => {
|
||||
});
|
||||
|
||||
it('does not duplicate destination row on re-wiring', async () => {
|
||||
const { getDestinations } = await import('./agent-destinations.js');
|
||||
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||
createMessagingGroupAgent(mga());
|
||||
// Re-create the same wiring throws (PK unique), but even if we got the
|
||||
// row in some other way (e.g. via createDestination directly followed
|
||||
@@ -251,7 +251,7 @@ describe('messaging group agents', () => {
|
||||
});
|
||||
|
||||
it('breaks local_name collisions within an agent group', async () => {
|
||||
const { getDestinations } = await import('./agent-destinations.js');
|
||||
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
||||
// Two messaging groups with the same `name` wired to the same agent
|
||||
// should get distinct local_names (gen, gen-2).
|
||||
createMessagingGroupAgent(mga());
|
||||
|
||||
+28
-13
@@ -1,11 +1,20 @@
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../types.js';
|
||||
// Transitional tier violation: core imports from optional agent-to-agent module.
|
||||
// `createMessagingGroupAgent` auto-creates a destination row on wiring — the
|
||||
// two concerns are currently bundled. When agent-to-agent isn't installed,
|
||||
// the table doesn't exist and this import chain remains dormant because
|
||||
// `createMessagingGroupAgent` is only called from setup/admin paths that
|
||||
// also only run when wiring channels to agents (which implicitly requires
|
||||
// agent-to-agent for the destination ACL to mean anything). A cleaner split
|
||||
// (or making the destination side effect module-owned) is tracked in the
|
||||
// refactor plan.
|
||||
import {
|
||||
createDestination,
|
||||
getDestinationByName,
|
||||
getDestinationByTarget,
|
||||
normalizeName,
|
||||
} from './agent-destinations.js';
|
||||
import { getDb } from './connection.js';
|
||||
} from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { getDb, hasTable } from './connection.js';
|
||||
|
||||
// ── Messaging Groups ──
|
||||
|
||||
@@ -84,21 +93,27 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
.run(mga);
|
||||
|
||||
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
||||
// outbound messages that target this chat.
|
||||
// outbound messages that target this chat. Guarded: when the agent-to-agent
|
||||
// module isn't installed the table doesn't exist — skip silently. Without
|
||||
// the module, the ACL check in delivery is also skipped (same guard), so
|
||||
// channel sends still work.
|
||||
//
|
||||
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||
// `agent_destinations` row. It does NOT project into any running
|
||||
// agent's session inbound.db (see top-of-file invariant in
|
||||
// src/db/agent-destinations.ts). In practice this is fine because the
|
||||
// only real callers are one-shot setup scripts (setup/register.ts,
|
||||
// scripts/init-first-agent.ts, /manage-channels skill) that run in a
|
||||
// separate process from the host. Any already-running container for
|
||||
// `mga.agent_group_id` will keep serving the stale projection until
|
||||
// its next wake (idle timeout or next inbound message) at which
|
||||
// point spawnContainer's writeDestinations call refreshes from central.
|
||||
// If you call this from code that runs INSIDE the host process and
|
||||
// need the refresh to happen immediately, explicitly call
|
||||
// `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
|
||||
// src/modules/agent-to-agent/db/agent-destinations.ts). In practice this
|
||||
// is fine because the only real callers are one-shot setup scripts
|
||||
// (setup/register.ts, scripts/init-first-agent.ts, /manage-channels
|
||||
// skill) that run in a separate process from the host. Any already-
|
||||
// running container for `mga.agent_group_id` will keep serving the
|
||||
// stale projection until its next wake (idle timeout or next inbound
|
||||
// message) at which point spawnContainer's writeDestinations call
|
||||
// refreshes from central. If you call this from code that runs INSIDE
|
||||
// the host process and need the refresh to happen immediately,
|
||||
// explicitly call the module's `writeDestinations(mga.agent_group_id,
|
||||
// <sessionId>)` afterwards.
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
|
||||
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
||||
if (existing) return;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type Database from 'better-sqlite3';
|
||||
import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { migration004 } from './004-agent-destinations.js';
|
||||
import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinations.js';
|
||||
import { migration008 } from './008-dropped-messages.js';
|
||||
import { migration009 } from './009-drop-pending-credentials.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
@@ -19,7 +19,7 @@ const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002,
|
||||
moduleApprovalsPendingApprovals,
|
||||
migration004,
|
||||
moduleAgentToAgentDestinations,
|
||||
moduleApprovalsTitleOptions,
|
||||
migration008,
|
||||
migration009,
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ import type { Migration } from './index.js';
|
||||
* while admin calls the child "worker-1". The (agent_group_id, local_name)
|
||||
* PK enforces uniqueness within a single agent's namespace only.
|
||||
*/
|
||||
export const migration004: Migration = {
|
||||
// Retains the original `name` ('agent-destinations') so existing DBs that
|
||||
// already recorded this migration under that name don't re-run it. The
|
||||
// module- prefix lives on the filename / export identifier only.
|
||||
export const moduleAgentToAgentDestinations: Migration = {
|
||||
version: 4,
|
||||
name: 'agent-destinations',
|
||||
up(db: Database.Database) {
|
||||
+25
-176
@@ -11,10 +11,8 @@ import type Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession } from './db/sessions.js';
|
||||
import { getAgentGroup, createAgentGroup, updateAgentGroup, getAgentGroupByFolder } from './db/agent-groups.js';
|
||||
import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import {
|
||||
@@ -26,19 +24,11 @@ import {
|
||||
} from './db/session-db.js';
|
||||
import { log } from './log.js';
|
||||
import { normalizeOptions } from './channels/ask-question.js';
|
||||
import {
|
||||
openInboundDb,
|
||||
openOutboundDb,
|
||||
sessionDir,
|
||||
resolveSession,
|
||||
writeDestinations,
|
||||
writeSessionMessage,
|
||||
} from './session-manager.js';
|
||||
import { openInboundDb, openOutboundDb, sessionDir, writeSessionMessage } from './session-manager.js';
|
||||
import { resetContainerIdleTimer, wakeContainer } from './container-runner.js';
|
||||
import { initGroupFilesystem } from './group-init.js';
|
||||
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import type { AgentGroup, Session } from './types.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
const ACTIVE_POLL_MS = 1000;
|
||||
const SWEEP_POLL_MS = 60_000;
|
||||
@@ -117,29 +107,6 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a system notification to an agent as a regular chat message.
|
||||
* Used for fire-and-forget responses from host actions (create_agent result,
|
||||
* approval outcomes, etc.). The agent sees it as an inbound chat message
|
||||
* with sender="system".
|
||||
*/
|
||||
function notifyAgent(session: Session, text: string): void {
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
// Wake the container so it picks up the notification promptly
|
||||
const fresh = getSession(session.id);
|
||||
if (fresh) {
|
||||
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the active container poll loop (~1s). */
|
||||
export function startActiveDeliveryPoll(): void {
|
||||
if (activePolling) return;
|
||||
@@ -293,45 +260,16 @@ async function deliverMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent-to-agent — route to target session (with permission check).
|
||||
// Permission is enforced via agent_destinations — the source agent must have
|
||||
// a row for the target. Content is copied verbatim; the target's formatter
|
||||
// will look up the source agent in its own local map to display a name.
|
||||
// Agent-to-agent — route to target session via the agent-to-agent module.
|
||||
// Guarded by the channel_type check. If the module isn't installed the
|
||||
// `agent_destinations` table won't exist and `routeAgentMessage`'s permission
|
||||
// check will throw, which falls into the normal retry → mark-failed path.
|
||||
if (msg.channel_type === 'agent') {
|
||||
const targetAgentGroupId = msg.platform_id;
|
||||
if (!targetAgentGroupId) {
|
||||
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
||||
if (!hasTable(getDb(), 'agent_destinations')) {
|
||||
throw new Error(`agent-to-agent module not installed — cannot route message ${msg.id}`);
|
||||
}
|
||||
// Self-messages are always allowed — used for system notes injected back
|
||||
// into an agent's own session (e.g. post-approval follow-up prompts).
|
||||
if (
|
||||
targetAgentGroupId !== session.agent_group_id &&
|
||||
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
|
||||
) {
|
||||
throw new Error(
|
||||
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
|
||||
);
|
||||
}
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: msg.content,
|
||||
});
|
||||
log.info('Agent message routed', {
|
||||
from: session.agent_group_id,
|
||||
to: targetAgentGroupId,
|
||||
targetSession: targetSession.id,
|
||||
});
|
||||
const fresh = getSession(targetSession.id);
|
||||
if (fresh) await wakeContainer(fresh);
|
||||
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
|
||||
await routeAgentMessage(msg, session);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -359,12 +297,19 @@ async function deliverMessage(
|
||||
const isOriginChat = session.messaging_group_id === mg.id;
|
||||
// Guarded: without the agent-to-agent module, `agent_destinations`
|
||||
// doesn't exist and we permit all non-origin channel sends (the
|
||||
// origin-chat case is always allowed regardless).
|
||||
const checkDestinations = hasTable(getDb(), 'agent_destinations');
|
||||
if (!isOriginChat && checkDestinations && !hasDestination(session.agent_group_id, 'channel', mg.id)) {
|
||||
throw new Error(
|
||||
`unauthorized channel destination: ${session.agent_group_id} cannot send to ${mg.channel_type}/${mg.platform_id}`,
|
||||
);
|
||||
// origin-chat case is always allowed regardless). Inlined SQL instead
|
||||
// of importing `hasDestination` so core doesn't depend on the module.
|
||||
if (!isOriginChat && hasTable(getDb(), 'agent_destinations')) {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1',
|
||||
)
|
||||
.get(session.agent_group_id, 'channel', mg.id);
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
`unauthorized channel destination: ${session.agent_group_id} cannot send to ${mg.channel_type}/${mg.platform_id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,103 +440,7 @@ async function handleSystemAction(
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'create_agent': {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
const instructions = content.instructions as string | null;
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
break;
|
||||
}
|
||||
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Derive a safe folder name, deduplicated globally across agent_groups.folder
|
||||
let folder = localName;
|
||||
let suffix = 2;
|
||||
while (getAgentGroupByFolder(folder)) {
|
||||
folder = `${localName}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const groupPath = path.join(GROUPS_DIR, folder);
|
||||
const resolvedPath = path.resolve(groupPath);
|
||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
break;
|
||||
}
|
||||
|
||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newGroup: AgentGroup = {
|
||||
id: agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: now,
|
||||
};
|
||||
createAgentGroup(newGroup);
|
||||
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined });
|
||||
|
||||
// Insert bidirectional destination rows (= ACL grants).
|
||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||
createDestination({
|
||||
agent_group_id: sourceGroup.id,
|
||||
local_name: localName,
|
||||
target_type: 'agent',
|
||||
target_id: agentGroupId,
|
||||
created_at: now,
|
||||
});
|
||||
// Handle the unlikely case where the child already has a "parent" destination
|
||||
// (shouldn't happen for a brand-new agent, but be safe).
|
||||
let parentName = 'parent';
|
||||
let parentSuffix = 2;
|
||||
while (getDestinationByName(agentGroupId, parentName)) {
|
||||
parentName = `parent-${parentSuffix}`;
|
||||
parentSuffix++;
|
||||
}
|
||||
createDestination({
|
||||
agent_group_id: agentGroupId,
|
||||
local_name: parentName,
|
||||
target_type: 'agent',
|
||||
target_id: sourceGroup.id,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
// REQUIRED: project the new destination into the running
|
||||
// container's inbound.db. See the top-of-file invariant in
|
||||
// src/db/agent-destinations.ts — forgetting this causes
|
||||
// "dropped: unknown destination" when the parent tries to send
|
||||
// to the newly-created child.
|
||||
writeDestinations(session.agent_group_id, session.id);
|
||||
|
||||
// Fire-and-forget notification back to the creator
|
||||
notifyAgent(
|
||||
session,
|
||||
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
||||
);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||
void requestId;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn('Unknown system action', { action });
|
||||
}
|
||||
log.warn('Unknown system action', { action });
|
||||
}
|
||||
|
||||
export function stopDeliveryPolls(): void {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Agent-to-agent message routing.
|
||||
*
|
||||
* Outbound messages with `channel_type === 'agent'` target another agent
|
||||
* group rather than a channel. Permission is enforced via `agent_destinations` —
|
||||
* the source agent must have a row for the target. Content is copied verbatim;
|
||||
* the target's formatter looks up the source agent in its own local map to
|
||||
* display a name.
|
||||
*
|
||||
* Self-messages are always allowed (used for system notes injected back into
|
||||
* an agent's own session, e.g. post-approval follow-up prompts).
|
||||
*
|
||||
* Core delivery.ts dispatches into this via a dynamic import guarded by a
|
||||
* `channel_type === 'agent'` check. When the module is absent the check in
|
||||
* core throws with a "module not installed" message so retry → mark failed.
|
||||
*/
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { resolveSession, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
|
||||
export interface RoutableAgentMessage {
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
|
||||
const targetAgentGroupId = msg.platform_id;
|
||||
if (!targetAgentGroupId) {
|
||||
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
||||
}
|
||||
if (
|
||||
targetAgentGroupId !== session.agent_group_id &&
|
||||
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
|
||||
) {
|
||||
throw new Error(
|
||||
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
|
||||
);
|
||||
}
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: msg.content,
|
||||
});
|
||||
log.info('Agent message routed', {
|
||||
from: session.agent_group_id,
|
||||
to: targetAgentGroupId,
|
||||
targetSession: targetSession.id,
|
||||
});
|
||||
const fresh = getSession(targetSession.id);
|
||||
if (fresh) await wakeContainer(fresh);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* `create_agent` delivery-action handler.
|
||||
*
|
||||
* Spawns a new agent group on demand from the parent agent, wires bidirectional
|
||||
* agent_destinations rows, projects the new destination into the parent's
|
||||
* running container, and notifies the parent.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { AgentGroup, Session } from '../../types.js';
|
||||
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
|
||||
import { writeDestinations } from './write-destinations.js';
|
||||
|
||||
function notifyAgent(session: Session, text: string): void {
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
const fresh = getSession(session.id);
|
||||
if (fresh) {
|
||||
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
const instructions = content.instructions as string | null;
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive a safe folder name, deduplicated globally across agent_groups.folder
|
||||
let folder = localName;
|
||||
let suffix = 2;
|
||||
while (getAgentGroupByFolder(folder)) {
|
||||
folder = `${localName}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const groupPath = path.join(GROUPS_DIR, folder);
|
||||
const resolvedPath = path.resolve(groupPath);
|
||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newGroup: AgentGroup = {
|
||||
id: agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: now,
|
||||
};
|
||||
createAgentGroup(newGroup);
|
||||
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined });
|
||||
|
||||
// Insert bidirectional destination rows (= ACL grants).
|
||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||
createDestination({
|
||||
agent_group_id: sourceGroup.id,
|
||||
local_name: localName,
|
||||
target_type: 'agent',
|
||||
target_id: agentGroupId,
|
||||
created_at: now,
|
||||
});
|
||||
// Handle the unlikely case where the child already has a "parent" destination
|
||||
// (shouldn't happen for a brand-new agent, but be safe).
|
||||
let parentName = 'parent';
|
||||
let parentSuffix = 2;
|
||||
while (getDestinationByName(agentGroupId, parentName)) {
|
||||
parentName = `parent-${parentSuffix}`;
|
||||
parentSuffix++;
|
||||
}
|
||||
createDestination({
|
||||
agent_group_id: agentGroupId,
|
||||
local_name: parentName,
|
||||
target_type: 'agent',
|
||||
target_id: sourceGroup.id,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
// REQUIRED: project the new destination into the running container's
|
||||
// inbound.db. See the top-of-file invariant in db/agent-destinations.ts
|
||||
// — forgetting this causes "dropped: unknown destination" when the parent
|
||||
// tries to send to the newly-created child.
|
||||
writeDestinations(session.agent_group_id, session.id);
|
||||
|
||||
// Fire-and-forget notification back to the creator
|
||||
notifyAgent(
|
||||
session,
|
||||
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
||||
);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||
void requestId;
|
||||
}
|
||||
@@ -32,8 +32,8 @@
|
||||
* - src/delivery.ts::handleSystemAction case 'create_agent'
|
||||
* - src/db/messaging-groups.ts::createMessagingGroupAgent
|
||||
*/
|
||||
import type { AgentDestination } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
import type { AgentDestination } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
/**
|
||||
* ⚠️ Caller responsibility: after this returns, call
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Agent-to-agent module — inter-agent messaging and on-demand agent creation.
|
||||
*
|
||||
* Registers one delivery action (`create_agent`). The sibling `channel_type === 'agent'`
|
||||
* routing path is NOT a system action — core `delivery.ts` dispatches into
|
||||
* `./agent-route.js` via a dynamic import when it sees `msg.channel_type === 'agent'`.
|
||||
*
|
||||
* Host integration points:
|
||||
* - `src/container-runner.ts::spawnContainer` dynamically imports
|
||||
* `./write-destinations.js` on every wake (guarded by `hasTable('agent_destinations')`).
|
||||
* - `src/delivery.ts::deliverMessage` dynamically imports `./agent-route.js`
|
||||
* when `msg.channel_type === 'agent'`.
|
||||
*
|
||||
* Without this module: `agent_destinations` table absent ⇒ container-runner
|
||||
* skips destination projection, ACL check in delivery skips, `create_agent`
|
||||
* system action logs "Unknown system action", `channel_type='agent'` messages
|
||||
* throw because the module isn't installed.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Project the agent's central `agent_destinations` rows into its per-session
|
||||
* `inbound.db` so the running container can resolve names locally. Called on
|
||||
* every container wake and after admin-time destination edits (e.g. create_agent).
|
||||
*
|
||||
* Core container-runner calls this via a dynamic import guarded by a
|
||||
* `hasTable('agent_destinations')` check — without the agent-to-agent module
|
||||
* installed, the central table doesn't exist and the projection is skipped.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { replaceDestinations, type DestinationRow } from '../../db/session-db.js';
|
||||
import { log } from '../../log.js';
|
||||
import { inboundDbPath, openInboundDb } from '../../session-manager.js';
|
||||
import { getDestinations } from './db/agent-destinations.js';
|
||||
|
||||
export function writeDestinations(agentGroupId: string, sessionId: string): void {
|
||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||
if (!fs.existsSync(dbPath)) return;
|
||||
|
||||
const rows = getDestinations(agentGroupId);
|
||||
const resolved: DestinationRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.target_type === 'channel') {
|
||||
const mg = getMessagingGroup(row.target_id);
|
||||
if (!mg) continue;
|
||||
resolved.push({
|
||||
name: row.local_name,
|
||||
display_name: mg.name ?? row.local_name,
|
||||
type: 'channel',
|
||||
channel_type: mg.channel_type,
|
||||
platform_id: mg.platform_id,
|
||||
agent_group_id: null,
|
||||
});
|
||||
} else if (row.target_type === 'agent') {
|
||||
const ag = getAgentGroup(row.target_id);
|
||||
if (!ag) continue;
|
||||
resolved.push({
|
||||
name: row.local_name,
|
||||
display_name: ag.name,
|
||||
type: 'agent',
|
||||
channel_type: null,
|
||||
platform_id: null,
|
||||
agent_group_id: ag.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const db = openInboundDb(agentGroupId, sessionId);
|
||||
try {
|
||||
replaceDestinations(db, resolved);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
log.debug('Destination map written', { sessionId, count: resolved.length });
|
||||
}
|
||||
@@ -17,4 +17,5 @@ import './interactive/index.js';
|
||||
import './approvals/index.js';
|
||||
import './scheduling/index.js';
|
||||
import './permissions/index.js';
|
||||
import './agent-to-agent/index.js';
|
||||
|
||||
|
||||
+3
-64
@@ -15,9 +15,6 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDestinations } from './db/agent-destinations.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js';
|
||||
import {
|
||||
@@ -25,10 +22,8 @@ import {
|
||||
openInboundDb as openInboundDbRaw,
|
||||
openOutboundDb as openOutboundDbRaw,
|
||||
upsertSessionRouting,
|
||||
replaceDestinations,
|
||||
insertMessage,
|
||||
migrateMessagesInTable,
|
||||
type DestinationRow,
|
||||
} from './db/session-db.js';
|
||||
import { log } from './log.js';
|
||||
import type { Session } from './types.js';
|
||||
@@ -130,16 +125,6 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
|
||||
ensureSchema(outboundDbPath(agentGroupId, sessionId), 'outbound');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the session's destination map into its inbound.db `destinations` table.
|
||||
*
|
||||
* Called before every container wake so admin changes take effect on next start —
|
||||
* but the container also re-queries on demand, so mid-session admin changes
|
||||
* (e.g. spawning a new child agent) can also call this to push the new map
|
||||
* without restarting the container.
|
||||
*
|
||||
* Uses DELETE + INSERT in a transaction for a clean overwrite.
|
||||
*/
|
||||
/**
|
||||
* Write the default reply routing for a session into its inbound.db.
|
||||
*
|
||||
@@ -147,8 +132,9 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
|
||||
* for outbound messages when the agent doesn't specify an explicit destination.
|
||||
* Derived from session.messaging_group_id → messaging_groups row + session.thread_id.
|
||||
*
|
||||
* Called on every container wake alongside writeDestinations() so the latest
|
||||
* routing is always in place, including after admin rewiring.
|
||||
* Called on every container wake alongside the agent-to-agent module's
|
||||
* writeDestinations() (when installed) so the latest routing is always in
|
||||
* place, including after admin rewiring.
|
||||
*/
|
||||
export function writeSessionRouting(agentGroupId: string, sessionId: string): void {
|
||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||
@@ -180,53 +166,6 @@ export function writeSessionRouting(agentGroupId: string, sessionId: string): vo
|
||||
log.debug('Session routing written', { sessionId, channelType, platformId, threadId: session.thread_id });
|
||||
}
|
||||
|
||||
export function writeDestinations(agentGroupId: string, sessionId: string): void {
|
||||
const dbPath = inboundDbPath(agentGroupId, sessionId);
|
||||
if (!fs.existsSync(dbPath)) return;
|
||||
|
||||
// Guarded: when the agent-to-agent module isn't installed, the
|
||||
// `agent_destinations` table doesn't exist. Skip silently — core
|
||||
// container spawn continues without projecting destinations.
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
|
||||
const rows = getDestinations(agentGroupId);
|
||||
const resolved: DestinationRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.target_type === 'channel') {
|
||||
const mg = getMessagingGroup(row.target_id);
|
||||
if (!mg) continue;
|
||||
resolved.push({
|
||||
name: row.local_name,
|
||||
display_name: mg.name ?? row.local_name,
|
||||
type: 'channel',
|
||||
channel_type: mg.channel_type,
|
||||
platform_id: mg.platform_id,
|
||||
agent_group_id: null,
|
||||
});
|
||||
} else if (row.target_type === 'agent') {
|
||||
const ag = getAgentGroup(row.target_id);
|
||||
if (!ag) continue;
|
||||
resolved.push({
|
||||
name: row.local_name,
|
||||
display_name: ag.name,
|
||||
type: 'agent',
|
||||
channel_type: null,
|
||||
platform_id: null,
|
||||
agent_group_id: ag.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const db = openInboundDb(agentGroupId, sessionId);
|
||||
try {
|
||||
replaceDestinations(db, resolved);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
log.debug('Destination map written', { sessionId, count: resolved.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a message to a session's inbound DB (messages_in). Host-only.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user