feat(FN-3612): preserve fusion context in hermes runtime skill forwarding
Merges five commits implementing centralized runtime skill forwarding that preserves Fusion context across the Hermes runtime layer. The engine's `agent-runtime` and `agent-session-helpers` were updated to forward skills at runtime, with `runtime-adapter.ts` and its types extended to carry context. Fusion-Task-Id: FN-3612
This commit is contained in:
5
.changeset/fn-3612-runtime-skill-context.md
Normal file
5
.changeset/fn-3612-runtime-skill-context.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Forward engine skill selection into runtime `skills` metadata for all session paths, and improve Hermes runtime behavior so first-turn prompts preserve Fusion system/skill context instead of silently dropping coordination capability hints on non-pi runtime runs.
|
||||||
@@ -4,6 +4,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
|||||||
|
|
||||||
- Source files: `packages/engine/src/agent-tools.ts`, `triage.ts`, `executor.ts`, `merger.ts`, `agent-heartbeat.ts`
|
- Source files: `packages/engine/src/agent-tools.ts`, `triage.ts`, `executor.ts`, `merger.ts`, `agent-heartbeat.ts`
|
||||||
- Availability: only when the engine creates a session for the matching agent role
|
- Availability: only when the engine creates a session for the matching agent role
|
||||||
|
- Runtime contract: engine sessions now forward requested skill names (`skillSelection.requestedSkillNames`) into the generic runtime `skills` field so non-pi runtimes can still receive Fusion skill intent.
|
||||||
- Important: do not tell users to call these directly from the generic extension tool list
|
- Important: do not tell users to call these directly from the generic extension tool list
|
||||||
|
|
||||||
## Shared runtime tools (`agent-tools.ts`)
|
## Shared runtime tools (`agent-tools.ts`)
|
||||||
@@ -28,8 +29,8 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
|||||||
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]) |
|
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]) |
|
||||||
| `fn_get_agent_config` | executor, heartbeat | Read full config for a direct-report agent | `agent_id` (string) |
|
| `fn_get_agent_config` | executor, heartbeat | Read full config for a direct-report agent | `agent_id` (string) |
|
||||||
| `fn_update_agent_config` | executor, heartbeat | Update config fields for a direct-report, non-ephemeral agent | `agent_id` (string), optional: `soul`, `instructions_text`, `instructions_path`, `heartbeat_procedure_path`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |
|
| `fn_update_agent_config` | executor, heartbeat | Update config fields for a direct-report, non-ephemeral agent | `agent_id` (string), optional: `soul`, `instructions_text`, `instructions_path`, `heartbeat_procedure_path`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |
|
||||||
| `fn_send_message` | executor, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) |
|
| `fn_send_message` | executor, step-session, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) |
|
||||||
| `fn_read_messages` | executor, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
|
| `fn_read_messages` | executor, step-session, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
|
||||||
|
|
||||||
## Triage-only runtime tools (`triage.ts`)
|
## Triage-only runtime tools (`triage.ts`)
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
|||||||
|
|
||||||
## Executor-only runtime tools (`executor.ts`)
|
## Executor-only runtime tools (`executor.ts`)
|
||||||
|
|
||||||
|
Note: step-session execution (`step-session-executor.ts`) reuses executor coordination tools (`fn_send_message`, `fn_read_messages`, `fn_list_agents`, `fn_delegate_task`, task-document tools, and memory tools) so spawned/session-sliced execution keeps parity with main executor runs.
|
||||||
|
|
||||||
| Tool | Purpose | Parameters |
|
| Tool | Purpose | Parameters |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) |
|
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) |
|
||||||
|
|||||||
@@ -165,6 +165,41 @@ describe("Hermes runtime integration via engine resolution pipeline", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards skillSelection.requestedSkillNames as runtime skills for plugin runtimes", async () => {
|
||||||
|
const hermesCreateSession = vi.fn().mockResolvedValue({
|
||||||
|
session: { runtime: "hermes", prompt: vi.fn() },
|
||||||
|
sessionFile: "/tmp/hermes.session.json",
|
||||||
|
});
|
||||||
|
const hermesRegistration = createHermesRegistration(() => ({
|
||||||
|
id: "hermes",
|
||||||
|
name: "Hermes Runtime",
|
||||||
|
createSession: hermesCreateSession,
|
||||||
|
promptWithFallback: vi.fn().mockResolvedValue(undefined),
|
||||||
|
describeModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pluginRunner = createMockPluginRunner({
|
||||||
|
getRuntimeById: vi.fn().mockReturnValue(hermesRegistration),
|
||||||
|
});
|
||||||
|
|
||||||
|
await createResolvedAgentSession({
|
||||||
|
sessionPurpose: "executor",
|
||||||
|
runtimeHint: "hermes",
|
||||||
|
pluginRunner,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
systemPrompt: "You are helpful",
|
||||||
|
skillSelection: {
|
||||||
|
projectRootDir: "/tmp/project",
|
||||||
|
requestedSkillNames: ["fusion"],
|
||||||
|
sessionPurpose: "executor",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hermesCreateSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
skills: ["fusion"],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to default pi runtime when Hermes factory throws", async () => {
|
it("falls back to default pi runtime when Hermes factory throws", async () => {
|
||||||
const hermesRegistration = createHermesRegistration(() => {
|
const hermesRegistration = createHermesRegistration(() => {
|
||||||
throw new Error("factory exploded");
|
throw new Error("factory exploded");
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ import type { FallbackModelUsedPayload } from "./pi.js";
|
|||||||
* Options for creating an agent session.
|
* Options for creating an agent session.
|
||||||
* Mirrors the options accepted by createFnAgent.
|
* Mirrors the options accepted by createFnAgent.
|
||||||
*/
|
*/
|
||||||
|
export interface AgentRuntimeContext {
|
||||||
|
sessionPurpose?: string;
|
||||||
|
toolMode?: "coding" | "readonly";
|
||||||
|
customToolNames?: string[];
|
||||||
|
requestedSkillNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentRuntimeOptions {
|
export interface AgentRuntimeOptions {
|
||||||
/** Working directory for the agent session */
|
/** Working directory for the agent session */
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -55,6 +62,8 @@ export interface AgentRuntimeOptions {
|
|||||||
skillSelection?: SkillSelectionContext;
|
skillSelection?: SkillSelectionContext;
|
||||||
/** Convenience: skill names to include in the session */
|
/** Convenience: skill names to include in the session */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
/** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */
|
||||||
|
runtimeContext?: AgentRuntimeContext;
|
||||||
/**
|
/**
|
||||||
* Last-chance abort hook fired by the runtime *immediately before* the
|
* Last-chance abort hook fired by the runtime *immediately before* the
|
||||||
* underlying LLM session is instantiated — i.e., after all of the runtime's
|
* underlying LLM session is instantiated — i.e., after all of the runtime's
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentRuntimeOptions } from "./agent-runtime.js";
|
import type { AgentRuntimeOptions } from "./agent-runtime.js";
|
||||||
|
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||||
import type { PluginRunner } from "./plugin-runner.js";
|
import type { PluginRunner } from "./plugin-runner.js";
|
||||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||||
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
|
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
|
||||||
@@ -17,6 +18,16 @@ import { promptWithFallback, describeModel } from "./pi.js";
|
|||||||
/** Logger for agent session helpers */
|
/** Logger for agent session helpers */
|
||||||
const sessionLog = createLogger("agent-session");
|
const sessionLog = createLogger("agent-session");
|
||||||
|
|
||||||
|
function extractSkillNamesFromSelection(skillSelection: SkillSelectionContext | undefined): string[] {
|
||||||
|
if (!skillSelection || !Array.isArray(skillSelection.requestedSkillNames)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillSelection.requestedSkillNames
|
||||||
|
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating an agent session with runtime resolution.
|
* Options for creating an agent session with runtime resolution.
|
||||||
*/
|
*/
|
||||||
@@ -115,7 +126,17 @@ export function extractRuntimeModel(
|
|||||||
export async function createResolvedAgentSession(
|
export async function createResolvedAgentSession(
|
||||||
options: ResolvedSessionOptions,
|
options: ResolvedSessionOptions,
|
||||||
): Promise<ResolvedSessionResult> {
|
): Promise<ResolvedSessionResult> {
|
||||||
const { sessionPurpose, pluginRunner, runtimeHint, ...runtimeOptions } = options;
|
const { sessionPurpose, pluginRunner, runtimeHint, ...runtimeOptionsRaw } = options;
|
||||||
|
|
||||||
|
const skillNamesFromSelection = extractSkillNamesFromSelection(runtimeOptionsRaw.skillSelection);
|
||||||
|
const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0
|
||||||
|
? runtimeOptionsRaw.skills
|
||||||
|
: skillNamesFromSelection;
|
||||||
|
|
||||||
|
const runtimeOptions: AgentRuntimeOptions = {
|
||||||
|
...runtimeOptionsRaw,
|
||||||
|
...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Build the resolution context
|
// Build the resolution context
|
||||||
const context = buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint);
|
const context = buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint);
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ Because we drive the CLI's `chat -q` mode:
|
|||||||
- **No per-token streaming.** Hermes buffers output through prompt_toolkit; the full response arrives once the process exits. `onText` is called exactly once per turn.
|
- **No per-token streaming.** Hermes buffers output through prompt_toolkit; the full response arrives once the process exits. `onText` is called exactly once per turn.
|
||||||
- **No reasoning/thinking deltas.** `-Q` mode suppresses them. If you need streaming + reasoning, switch to Hermes's ACP mode (not yet implemented in this plugin).
|
- **No reasoning/thinking deltas.** `-Q` mode suppresses them. If you need streaming + reasoning, switch to Hermes's ACP mode (not yet implemented in this plugin).
|
||||||
- **No tool-call hooks.** Hermes runs tools internally; Fusion only sees the final assistant text. Use `yolo: true` to skip Hermes's interactive approval prompts in non-interactive sessions.
|
- **No tool-call hooks.** Hermes runs tools internally; Fusion only sees the final assistant text. Use `yolo: true` to skip Hermes's interactive approval prompts in non-interactive sessions.
|
||||||
- **`AgentRuntimeOptions.cwd`, `tools`, `skills`, `sessionManager`, etc. are ignored** — Hermes's own session/tools/skills systems handle these.
|
- **No JS tool callbacks.** `customTools` callback functions are still not executable through Hermes CLI mode; Hermes runs its own tool layer and Fusion receives final text.
|
||||||
|
- **Fusion context is prompt-mediated.** The engine forwards requested Fusion skill names into `skills`, and the adapter prepends Fusion system/runtime context on the first turn of each session so capability expectations (for example messaging/delegation flows) are not silently dropped on non-pi runtimes.
|
||||||
|
- `AgentRuntimeOptions.cwd` / `sessionManager` are still adapter-noops in CLI mode.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => {
|
|||||||
|
|
||||||
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||||
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0];
|
const [prompt, settings, resumeId] = mockInvoke.mock.calls[0];
|
||||||
expect(prompt).toBe("first prompt");
|
expect(prompt).toContain("User request:\nfirst prompt");
|
||||||
|
expect(prompt).toContain("Fusion runtime context:");
|
||||||
expect(settings.model).toBe("claude-sonnet-4-5");
|
expect(settings.model).toBe("claude-sonnet-4-5");
|
||||||
expect(resumeId).toBeUndefined();
|
expect(resumeId).toBeUndefined();
|
||||||
expect(onText).toHaveBeenCalledWith("hello from hermes");
|
expect(onText).toHaveBeenCalledWith("hello from hermes");
|
||||||
@@ -78,7 +79,8 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => {
|
|||||||
await adapter.promptWithFallback(session, "p1");
|
await adapter.promptWithFallback(session, "p1");
|
||||||
await adapter.promptWithFallback(session, "p2");
|
await adapter.promptWithFallback(session, "p2");
|
||||||
|
|
||||||
const [, , resume2] = mockInvoke.mock.calls[1];
|
const [prompt2, , resume2] = mockInvoke.mock.calls[1];
|
||||||
|
expect(prompt2).toBe("p2");
|
||||||
expect(resume2).toBe("20260427_120000_abc123");
|
expect(resume2).toBe("20260427_120000_abc123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,28 @@ import type {
|
|||||||
HermesStreamSession,
|
HermesStreamSession,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
function buildRuntimeContextSection(options: AgentRuntimeOptions): string {
|
||||||
|
const skillNames = Array.isArray(options.skills) ? options.skills.filter((value): value is string => typeof value === "string" && value.trim().length > 0) : [];
|
||||||
|
const skillSelection = options.skillSelection as { requestedSkillNames?: unknown } | undefined;
|
||||||
|
const selectionSkillNames = Array.isArray(skillSelection?.requestedSkillNames)
|
||||||
|
? skillSelection.requestedSkillNames.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
const mergedSkills = skillNames.length > 0 ? skillNames : selectionSkillNames;
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
"Fusion runtime context:",
|
||||||
|
`- Tool mode: ${options.tools ?? "coding"}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (mergedSkills.length > 0) {
|
||||||
|
lines.push(`- Requested skills: ${mergedSkills.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("- If fn_* tools are available in your runtime, use them directly for coordination/memory/task actions.");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export class HermesRuntimeAdapter implements AgentRuntime {
|
export class HermesRuntimeAdapter implements AgentRuntime {
|
||||||
readonly id = "hermes";
|
readonly id = "hermes";
|
||||||
readonly name = "Hermes Runtime";
|
readonly name = "Hermes Runtime";
|
||||||
@@ -43,6 +65,8 @@ export class HermesRuntimeAdapter implements AgentRuntime {
|
|||||||
onToolStart: options.onToolStart,
|
onToolStart: options.onToolStart,
|
||||||
onToolEnd: options.onToolEnd,
|
onToolEnd: options.onToolEnd,
|
||||||
},
|
},
|
||||||
|
runtimeContext: options.runtimeContext,
|
||||||
|
fusedSystemPrompt: [options.systemPrompt.trim(), buildRuntimeContextSection(options).trim()].filter((part) => part.length > 0).join("\n\n"),
|
||||||
dispose: () => undefined,
|
dispose: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +79,10 @@ export class HermesRuntimeAdapter implements AgentRuntime {
|
|||||||
_options?: unknown,
|
_options?: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resumeId = session.sessionId || undefined;
|
const resumeId = session.sessionId || undefined;
|
||||||
const result = await invokeHermesCli(prompt, this.settings, resumeId);
|
const promptWithContext = resumeId
|
||||||
|
? prompt
|
||||||
|
: `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}`;
|
||||||
|
const result = await invokeHermesCli(promptWithContext, this.settings, resumeId);
|
||||||
|
|
||||||
session.sessionId = result.sessionId;
|
session.sessionId = result.sessionId;
|
||||||
session.lastModelDescription = this.describeFromSettings();
|
session.lastModelDescription = this.describeFromSettings();
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export interface HermesCallbacks {
|
|||||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HermesRuntimeContext {
|
||||||
|
sessionPurpose?: string;
|
||||||
|
toolMode?: "coding" | "readonly";
|
||||||
|
customToolNames?: string[];
|
||||||
|
requestedSkillNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HermesStreamSession {
|
export interface HermesStreamSession {
|
||||||
model: unknown;
|
model: unknown;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
@@ -22,6 +29,8 @@ export interface HermesStreamSession {
|
|||||||
lastModelDescription: string;
|
lastModelDescription: string;
|
||||||
callbacks: HermesCallbacks;
|
callbacks: HermesCallbacks;
|
||||||
usage?: unknown;
|
usage?: unknown;
|
||||||
|
runtimeContext?: HermesRuntimeContext;
|
||||||
|
fusedSystemPrompt: string;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +58,7 @@ export interface AgentRuntimeOptions {
|
|||||||
sessionManager?: unknown;
|
sessionManager?: unknown;
|
||||||
skillSelection?: unknown;
|
skillSelection?: unknown;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
runtimeContext?: HermesRuntimeContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of creating a session. */
|
/** Result of creating a session. */
|
||||||
|
|||||||
Reference in New Issue
Block a user