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:
Fusion
2026-05-06 12:57:21 -07:00
committed by gsxdsm
parent 2571d14d6e
commit 8df5d2617c
9 changed files with 121 additions and 7 deletions

View File

@@ -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`
- 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
## 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_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_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_read_messages` | executor, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
| `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, step-session, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
## 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`)
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 |
|---|---|---|
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) |

View File

@@ -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 () => {
const hermesRegistration = createHermesRegistration(() => {
throw new Error("factory exploded");

View File

@@ -22,6 +22,13 @@ import type { FallbackModelUsedPayload } from "./pi.js";
* Options for creating an agent session.
* Mirrors the options accepted by createFnAgent.
*/
export interface AgentRuntimeContext {
sessionPurpose?: string;
toolMode?: "coding" | "readonly";
customToolNames?: string[];
requestedSkillNames?: string[];
}
export interface AgentRuntimeOptions {
/** Working directory for the agent session */
cwd: string;
@@ -55,6 +62,8 @@ export interface AgentRuntimeOptions {
skillSelection?: SkillSelectionContext;
/** Convenience: skill names to include in the session */
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
* underlying LLM session is instantiated — i.e., after all of the runtime's

View File

@@ -8,6 +8,7 @@
*/
import type { AgentRuntimeOptions } from "./agent-runtime.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
@@ -17,6 +18,16 @@ import { promptWithFallback, describeModel } from "./pi.js";
/** Logger for agent session helpers */
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.
*/
@@ -115,7 +126,17 @@ export function extractRuntimeModel(
export async function createResolvedAgentSession(
options: ResolvedSessionOptions,
): 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
const context = buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint);