Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ export type HonchoConfig = {
noisePatterns: string[];
disableDefaultNoisePatterns: boolean;
ownerObserveOthers: boolean;
/**
* Optional per-agent workspace routing.
* Maps agent ID prefixes to Honcho workspace IDs.
* Agents whose IDs start with a matching prefix will use the mapped workspace.
* Agents not matching any prefix fall back to the default `workspaceId`.
*
* Example:
* ```json
* {
* "nr_": "neoreef",
* "hb_": "her-beauty",
* "lc_": "lifecycle"
* }
* ```
*/
workspaceMapping?: Record<string, string>;
};

/**
Expand Down Expand Up @@ -55,6 +71,20 @@ export const honchoConfigSchema = {
...new Set([...(disableDefaultNoisePatterns ? [] : DEFAULT_NOISE_PATTERNS), ...userPatterns]),
];

// Parse optional workspace mapping (agentId prefix → workspaceId)
let workspaceMapping: Record<string, string> | undefined;
if (cfg.workspaceMapping && typeof cfg.workspaceMapping === "object" && !Array.isArray(cfg.workspaceMapping)) {
const mapping: Record<string, string> = {};
for (const [prefix, wsId] of Object.entries(cfg.workspaceMapping as Record<string, unknown>)) {
if (typeof prefix === "string" && prefix.length > 0 && typeof wsId === "string" && wsId.length > 0) {
mapping[prefix] = wsId;
}
}
if (Object.keys(mapping).length > 0) {
workspaceMapping = mapping;
}
}
Comment on lines +82 to +94
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast on malformed workspaceMapping entries.

This silently drops bad prefixes/workspace IDs. Because unmatched agents fall back to workspaceId, a typo here routes traffic back into the shared default workspace—the opposite of the isolation this feature is adding. Trim and reject invalid entries during parse instead of ignoring them.

🛠️ Suggested hardening
-      for (const [prefix, wsId] of Object.entries(cfg.workspaceMapping as Record<string, unknown>)) {
-        if (typeof prefix === "string" && prefix.length > 0 && typeof wsId === "string" && wsId.length > 0) {
-          mapping[prefix] = wsId;
-        }
-      }
+      for (const [rawPrefix, rawWsId] of Object.entries(cfg.workspaceMapping as Record<string, unknown>)) {
+        const prefix = rawPrefix.trim().toLowerCase();
+        const wsId = typeof rawWsId === "string" ? rawWsId.trim() : "";
+        if (!prefix || !wsId) {
+          throw new Error(`Invalid workspaceMapping entry for prefix "${rawPrefix}"`);
+        }
+        mapping[prefix] = wsId;
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Parse optional workspace mapping (agentId prefix → workspaceId)
let workspaceMapping: Record<string, string> | undefined;
if (cfg.workspaceMapping && typeof cfg.workspaceMapping === "object" && !Array.isArray(cfg.workspaceMapping)) {
const mapping: Record<string, string> = {};
for (const [prefix, wsId] of Object.entries(cfg.workspaceMapping as Record<string, unknown>)) {
if (typeof prefix === "string" && prefix.length > 0 && typeof wsId === "string" && wsId.length > 0) {
mapping[prefix] = wsId;
}
}
if (Object.keys(mapping).length > 0) {
workspaceMapping = mapping;
}
}
// Parse optional workspace mapping (agentId prefix → workspaceId)
let workspaceMapping: Record<string, string> | undefined;
if (cfg.workspaceMapping && typeof cfg.workspaceMapping === "object" && !Array.isArray(cfg.workspaceMapping)) {
const mapping: Record<string, string> = {};
for (const [rawPrefix, rawWsId] of Object.entries(cfg.workspaceMapping as Record<string, unknown>)) {
const prefix = rawPrefix.trim().toLowerCase();
const wsId = typeof rawWsId === "string" ? rawWsId.trim() : "";
if (!prefix || !wsId) {
throw new Error(`Invalid workspaceMapping entry for prefix "${rawPrefix}"`);
}
mapping[prefix] = wsId;
}
if (Object.keys(mapping).length > 0) {
workspaceMapping = mapping;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config.ts` around lines 74 - 86, The workspaceMapping parsing silently drops
invalid entries; update the cfg.workspaceMapping handling (the workspaceMapping
variable and the loop over Object.entries(cfg.workspaceMapping)) to trim both
prefix and wsId, validate that after trimming both are non-empty strings, and if
any entry fails validation throw an error (or otherwise fail-fast) so malformed
prefixes/IDs aren’t ignored and mistakenly fall back to the default workspaceId;
only after all entries pass validation populate and return the mapping object.


return {
apiKey,
workspaceId:
Expand All @@ -68,6 +98,7 @@ export const honchoConfigSchema = {
noisePatterns,
disableDefaultNoisePatterns,
ownerObserveOthers: typeof cfg.ownerObserveOthers === "boolean" ? cfg.ownerObserveOthers : false,
workspaceMapping,
};
},
};
26 changes: 22 additions & 4 deletions hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ async function flushMessages(
const isSubagent = isSubagentSession(ctx);
const parentAgentId = isSubagent ? subagentParentMap.get(ctx.sessionKey ?? "") : undefined;

await state.ensureInitialized();
// Resolve workspace and get the correct Honcho client for this agent
const workspaceId = state.resolveWorkspace(agentId);
const honcho = state.getHonchoClient(workspaceId);

await state.ensureInitialized(workspaceId);
Comment on lines +29 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep parentPeer in the same workspace as the routed session.

The session is now created in workspaceId, but parentPeer is still later resolved from parentAgentId and attached to that same session. If the parent and subagent prefixes map to different workspaces, this reconnects two tenant scopes in the capture path. Only fetch/add parentPeer when state.resolveWorkspace(parentAgentId) === workspaceId; otherwise keep just the parent agent ID in metadata.

🔧 Suggested guard
+  const parentWorkspaceId =
+    isSubagent && parentAgentId ? state.resolveWorkspace(parentAgentId) : null;
   const parentPeer =
-    isSubagent && parentAgentId && parentAgentId !== agentId
+    isSubagent &&
+    parentAgentId &&
+    parentAgentId !== agentId &&
+    parentWorkspaceId === workspaceId
       ? await state.getAgentPeer(parentAgentId)
       : null;

Also applies to: 48-48

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/capture.ts` around lines 29 - 33, The session is created in workspaceId
but parentPeer is later resolved from parentAgentId without verifying workspace
affinity; change the logic around resolving/adding parentPeer so you only call
state.resolveWorkspace(parentAgentId) and fetch/add parentPeer when that result
=== workspaceId (i.e., same tenant); otherwise do not call
state.getHonchoClient/get peer or attach a peer object to the session — include
only the parentAgentId in the metadata. Apply the same guard where parentPeer is
handled elsewhere (around the code referenced at lines near 48), ensuring any
call sites that resolve or attach parentPeer perform the same workspace equality
check against workspaceId before fetching peers or mutating the session.

const agentPeer = await state.getAgentPeer(agentId);
const parentPeer =
isSubagent && parentAgentId && parentAgentId !== agentId
Expand All @@ -41,7 +45,7 @@ async function flushMessages(
} : {}),
};

const session = await state.honcho.session(sessionKey, { metadata: sessionMeta });
const session = await honcho.session(sessionKey, { metadata: sessionMeta });
const meta = await session.getMetadata();
const existingMeta: Record<string, unknown> =
meta && typeof meta === "object" ? (meta as Record<string, unknown>) : {};
Expand Down Expand Up @@ -70,14 +74,28 @@ async function flushMessages(
}

const newRawMessages = messages.slice(startIndex);
const extracted = extractMessages(newRawMessages, state.ownerPeer!, agentPeer, state.cfg.noisePatterns);
const ownerPeer = state.getOwnerPeer(workspaceId)!;
const extracted = extractMessages(newRawMessages, ownerPeer, agentPeer, state.cfg.noisePatterns);

if (extracted.length === 0) {
await session.setMetadata({ ...existingMeta, ...sessionMeta, lastSavedIndex: messages.length });
return 0;
}

await session.addMessages(extracted);
// Honcho API enforces a 100-message-per-request limit.
// Batch to stay under that ceiling and advance lastSavedIndex per batch
// so partial progress is preserved if a later batch fails.
const BATCH_SIZE = 50;
let saved = 0;
for (let i = 0; i < extracted.length; i += BATCH_SIZE) {
const batch = extracted.slice(i, i + BATCH_SIZE);
await session.addMessages(batch);
saved += batch.length;
// Advance index after each successful batch so we don't re-send on retry
const progressIndex = startIndex + saved;
await session.setMetadata({ ...existingMeta, ...sessionMeta, lastSavedIndex: progressIndex });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await session.setMetadata({ ...existingMeta, ...sessionMeta, lastSavedIndex: messages.length });
return extracted.length;
}
Expand Down
13 changes: 9 additions & 4 deletions hooks/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState):
const agentId = ctx.agentId ?? state.resolveDefaultAgentId();
const isSubagent = isSubagentSession(ctx);

// Resolve workspace and get the correct Honcho client for this agent
const workspaceId = state.resolveWorkspace(agentId);
const honcho = state.getHonchoClient(workspaceId);

state.turnStartIndex.set(sessionKey, event.messages.length);

try {
await state.ensureInitialized();
await state.ensureInitialized(workspaceId);
const agentPeer = await state.getAgentPeer(agentId);
const ownerPeer = state.getOwnerPeer(workspaceId)!;
Comment on lines 11 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route subagent context through the parent’s workspace.

This resolves the workspace from ctx.agentId only. hooks/subagent.ts (Lines 17-32) already records the parent agent for child sessions; if a spawned subagent ID doesn’t carry the parent’s prefix, before_prompt_build pulls memory from the default workspace instead of the org workspace. Resolve/init with parentAgentId ?? agentId, and keep the child peer lookup pinned to that same workspace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/context.ts` around lines 11 - 23, The workspace resolution and
initialization should use the parent agent when present: compute const
effectiveAgentId = parentAgentId ?? agentId (pull parentAgentId from the
session/context where subagent handling stores it), then call
state.resolveWorkspace(effectiveAgentId) and
state.ensureInitialized(workspaceId) using that workspaceId, and use that same
workspaceId when calling state.getHonchoClient(workspaceId) and
state.getAgentPeer(agentId) (keep getAgentPeer using the original child agentId
but lookup it up within the resolved parent workspace) so memory/prompts are
resolved against the parent/org workspace rather than the default workspace.


const sections: string[] = [];

if (isSubagent) {
try {
const peerCtx = await agentPeer.context({ target: state.ownerPeer! });
const peerCtx = await agentPeer.context({ target: ownerPeer });
if (peerCtx.peerCard?.length) {
sections.push(`Key facts:\n${peerCtx.peerCard.map((f: string) => `• ${f}`).join("\n")}`);
}
Expand All @@ -36,14 +41,14 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState):
throw e;
}
} else {
const session = await state.honcho.session(sessionKey, { metadata: { agentId } });
const session = await honcho.session(sessionKey, { metadata: { agentId } });

let context;
try {
context = await session.context({
summary: true,
tokens: 2000,
peerTarget: state.ownerPeer!,
peerTarget: ownerPeer,
peerPerspective: agentPeer,
});
} catch (e: unknown) {
Expand Down
10 changes: 10 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
"type": "boolean",
"default": false,
"description": "Whether the owner peer observes agent messages. When true, Honcho models the user's awareness of assistant responses."
},
"workspaceMapping": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Optional per-agent workspace routing. Maps agent ID prefixes to Honcho workspace IDs. Agents not matching any prefix use the default workspaceId."
}
}
},
Expand Down Expand Up @@ -55,6 +60,11 @@
"label": "Owner Observes Agent",
"advanced": true,
"help": "When enabled, Honcho models the user as aware of the agent's responses. Default: off."
},
"workspaceMapping": {
"label": "Workspace Mapping",
"advanced": true,
"help": "Map agent ID prefixes to Honcho workspace IDs for multi-org isolation. Example: {\"nr_\": \"neoreef\", \"hb_\": \"her-beauty\"}. Agents not matching any prefix use the default workspaceId."
}
}
}
Loading