Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ updates:
prefix: "deps"
include: "scope"
groups:
# pi-agent-core depends on pi-ai; bumping them in separate PRs leaves two
# pi-ai copies in the workspace and breaks the StreamFn types.
pi:
patterns:
- "@earendil-works/*"
typescript:
patterns:
- "typescript"
Expand Down
8 changes: 8 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ id, endpoint, credential, headers, token limits, and relevant provider options
still come from OpenCode. An unmapped transport fails during model binding
instead of silently switching models or protocols.

When OpenCode supplies a custom `fetch` for authentication or routing, the bridge
passes it to the provider without changing the process-wide fetch function.
The Codex transport uses SSE in this mode; an explicit WebSocket request is
rejected. Custom fetch is not supported by the Google Gemini, Google Vertex,
or Amazon Bedrock transports, so those combinations fail before making a
provider request. Their ordinary host-resolved credential paths remain available
when no custom fetch is supplied.

For `@ai-sdk/anthropic`, configure OpenCode's `baseURL` through the `/v1` prefix.
OpenCode appends `/messages`; the MemFlywheel bridge removes that terminal `/v1`
before handing the same endpoint to pi-ai, whose Anthropic SDK appends
Expand Down
46 changes: 40 additions & 6 deletions e2e/agents.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ const HERMES_POD = "hermes-agent";
const HERMES_HOME = "/opt/data";
const PLUGIN_DIR = `${HERMES_HOME}/plugins/memflywheel`;

const HERMES_MEMORY_PROVIDER = `
import json
import sys
from ruamel.yaml import YAML
yaml = YAML(typ='safe')
yaml.version = (1, 1)
with open(sys.argv[1]) as source:
config = yaml.load(source) or {}
print(json.dumps((config.get('memory') or {}).get('provider', '')))
`;

function hermesMemoryProvider() {
return JSON.parse(
kubectlExec(
HERMES_NS,
HERMES_POD,
"bash",
"-c",
'exec "${HERMES_PYTHON:?HERMES_PYTHON is required}" -c "$1" "$2"',
"_",
HERMES_MEMORY_PROVIDER,
`${HERMES_HOME}/config.yaml`,
),
);
}

function hermesChat(prompt) {
return kubectlExec(
HERMES_NS,
Expand All @@ -41,7 +67,11 @@ async function hermesWaitForSetup() {
for (let i = 0; i < 30; i++) {
try {
const listing = kubectlExec(HERMES_NS, HERMES_POD, "ls", PLUGIN_DIR);
if (listing.includes("__init__.py") && listing.includes("worker.mjs")) {
if (
listing.includes("__init__.py") &&
listing.includes("worker.mjs") &&
hermesMemoryProvider() === "memflywheel"
) {
installed = true;
break;
}
Expand All @@ -50,7 +80,11 @@ async function hermesWaitForSetup() {
}
await delay(2000);
}
check("memflywheel plugin installed", installed, installed ? "files present" : "timed out");
check(
"memflywheel plugin installed",
installed,
installed ? "files and configuration ready" : "timed out",
);
if (!installed) process.exit(1);
}

Expand All @@ -72,10 +106,10 @@ function hermesVerifySetup() {
check("plugin install.json present", false);
}
try {
const config = kubectlExec(HERMES_NS, HERMES_POD, "cat", `${HERMES_HOME}/config.yaml`);
check("memory.provider configured", /provider:\s*memflywheel/m.test(config), "memflywheel");
} catch {
check("memory.provider configured", false, "config.yaml not found");
const provider = hermesMemoryProvider();
check("memory.provider configured", provider === "memflywheel", provider || "unset");
} catch (e) {
check("memory.provider configured", false, e.message?.slice(0, 100));
}
}

Expand Down
30 changes: 19 additions & 11 deletions e2e/kind/03-memflywheel-install.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/bash
#!/command/with-contenv bash
# s6-overlay cont-init script: install memflywheel plugin into HERMES_HOME.
#
# Runs as root after 01-hermes-setup (which creates $HERMES_HOME and seeds
Expand Down Expand Up @@ -29,7 +29,7 @@ if [ -f "$CONFIG" ] && ! grep -q "disabled_toolsets" "$CONFIG"; then
fi

# Run the install script (installed globally from @iflytekopensource/memflywheel)
memflywheel-hermes-install || {
/usr/local/bin/memflywheel-hermes-install || {
echo "[memflywheel] ERROR: memflywheel-hermes-install failed"
exit 1
}
Expand All @@ -42,16 +42,21 @@ if [ -d "$PLUGIN_DIR" ]; then
ln -sf /usr/local/lib/node_modules "${PLUGIN_DIR}/node_modules" 2>/dev/null || true
fi

# Update the model and memory config in $HERMES_HOME/config.yaml.
# `hermes config set` writes to a different location (.hermes/config.yaml),
# so we modify the YAML directly.
# Use the host's Python/YAML runtime to update $HERMES_HOME/config.yaml.
CONFIG="${HERMES_HOME}/config.yaml"
if [ -f "$CONFIG" ]; then
python3 -c "
import yaml
"${HERMES_PYTHON:?HERMES_PYTHON is required}" -c "
from ruamel.yaml import YAML
yaml = YAML(typ='safe')
yaml.version = (1, 1)
yaml.default_flow_style = False
with open('${CONFIG}') as f:
cfg = yaml.safe_load(f) or {}
cfg.setdefault('model', {})
cfg = yaml.load(f) or {}
model = cfg.get('model')
if model is None or isinstance(model, str):
cfg['model'] = {}
elif not isinstance(model, dict):
raise TypeError('model must be a string or mapping')
cfg['model']['default'] = 'mock-llm'
cfg['model']['provider'] = 'custom'
cfg['model']['base_url'] = 'http://mock-llm.default.svc.cluster.local:8080/v1'
Expand All @@ -61,9 +66,12 @@ cfg['memory']['provider'] = 'memflywheel'
cfg.setdefault('agent', {})
cfg['agent']['disabled_toolsets'] = ['memory']
with open('${CONFIG}', 'w') as f:
yaml.dump(cfg, f, default_flow_style=False)
yaml.dump(cfg, f)
print('[memflywheel] Config updated: model=mock-llm, memory.provider=memflywheel, disabled_toolsets=[memory]')
" || echo "[memflywheel] WARNING: Failed to update config.yaml"
"
else
echo "[memflywheel] ERROR: config.yaml is missing"
exit 1
fi

# Ensure the plugin directory is owned by hermes (we're running as root)
Expand Down
9 changes: 7 additions & 2 deletions e2e/kind/Dockerfile.hermes
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ FROM nousresearch/hermes-agent:latest

USER root

# Verify the configuration writer uses the Python environment supplied by Hermes.
RUN "${HERMES_PYTHON:?HERMES_PYTHON is required}" -c "from ruamel.yaml import YAML"

# Copy the unified memflywheel package
COPY e2e/packages/iflytekopensource-memflywheel-*.tgz /tmp/memflywheel.tgz

# Install it globally so memflywheel-hermes-install is available
RUN npm install --global /tmp/memflywheel.tgz
# Keep the install prefix stable when the base image uses a managed Node binary.
RUN npm install --global --prefix /usr/local /tmp/memflywheel.tgz \
&& test -x /usr/local/bin/memflywheel-hermes-install \
&& /usr/local/bin/memflywheel-hermes-install --help

# Make globally-installed packages resolvable from any working directory
ENV NODE_PATH=/usr/local/lib/node_modules
Expand Down
19 changes: 16 additions & 3 deletions packages/memflywheel/bridge/worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ import { dirname, join } from "node:path";
const memflywheel = await import(
process.env.MEMFLYWHEEL_PACKAGE_IMPORT || "@iflytekopensource/memflywheel"
);
const { createAssistantMessageEventStream, createMemFlywheelHarnessRuntime, normalizeMessages } =
memflywheel;
const {
createAssistantMessageEventStream,
createMemFlywheelHarnessRuntime,
getCurrentSystemPrompt,
getCurrentTools,
normalizeMessages,
} = memflywheel;

let runtime;
let runtimeKey = "";
Expand Down Expand Up @@ -75,11 +80,19 @@ function resolveModel() {
return {
model: hermesModel,
streamFn: async (_model, context, options) => {
const message = await complete({ context, signal: options?.signal?.aborted === true });
const message = await complete({
context: {
systemPrompt: getCurrentSystemPrompt(context.messages),
messages: context.messages.filter((entry) => entry.role !== "system"),
tools: getCurrentTools(context.messages),
},
signal: options?.signal?.aborted === true,
});
const stream = createAssistantMessageEventStream();
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "error", reason: message.stopReason, error: message });
} else {
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: message.stopReason, message });
}
return stream;
Expand Down
4 changes: 2 additions & 2 deletions packages/memflywheel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.3",
"@earendil-works/pi-ai": "^0.84.4",
"@earendil-works/pi-agent-core": "^0.99.2",
"@earendil-works/pi-ai": "^0.99.2",
"proper-lockfile": "^4.1.2"
},
"devDependencies": {
Expand Down
108 changes: 108 additions & 0 deletions packages/memflywheel/src/hermes-worker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import type { AssistantMessage, JsonObject } from "@earendil-works/pi-ai";

test(
"Hermes worker preserves host instructions and tools across model RPC",
{ timeout: 30_000 },
async (t) => {
const root = await mkdtemp(join(tmpdir(), "memflywheel-hermes-rpc-"));
const worker = spawn(
process.execPath,
[fileURLToPath(new URL("../bridge/worker.mjs", import.meta.url))],
{
cwd: root,
env: {
...process.env,
MEMFLYWHEEL_PACKAGE_IMPORT: new URL("./index.js", import.meta.url).href,
},
stdio: "pipe",
},
);
const closed = once(worker, "close");
const lines = createInterface({ input: worker.stdout });
const responses = lines[Symbol.asyncIterator]();
let stderr = "";
worker.stderr.setEncoding("utf8").on("data", (chunk) => {
stderr += chunk;
});
t.after(async () => {
lines.close();
worker.kill();
await closed;
await rm(root, { recursive: true, force: true });
});
const send = (message: object) => worker.stdin.write(`${JSON.stringify(message)}\n`);
let modelCalls = 0;
async function command(name: string, payload: object) {
send({ type: "command", id: name, command: name, payload });
for (;;) {
const line = await responses.next();
assert.equal(line.done, false, stderr);
const message = JSON.parse(line.value);
if (message.type === "command_response") {
assert.equal(message.id, name);
assert.equal(message.error, undefined);
return;
}
assert.equal(message.type, "model_request");
const context = message.request.context;
assert.match(context.systemPrompt, /memory extraction engine/);
assert.ok(context.tools.some((tool: { name: string }) => tool.name === "write"));
assert.ok(context.messages.every((entry: { role: string }) => entry.role !== "system"));
modelCalls += 1;
const content: AssistantMessage["content"] = [];
if (modelCalls === 1) {
const args: JsonObject = {
filePath: "preference/tea.md",
content:
"---\ntype: preference\nname: Tea\ndescription: Preferred drink\n---\nThe user prefers tea.\n",
};
content.push({ type: "toolCall", id: "write-tea", name: "write", arguments: args });
} else {
assert.ok(
context.messages.some((entry: { role: string }) => entry.role === "toolResult"),
);
content.push({ type: "text", text: "done" });
}
const result: AssistantMessage = {
role: "assistant",
content,
api: "openai-completions",
provider: "hermes",
model: "host-active",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: modelCalls === 1 ? "toolUse" : "stop",
timestamp: Date.now(),
};
send({ type: "model_response", id: message.id, result });
}
}
const payload = { sessionId: "hermes-rpc", root, learnedSkills: false };
await command("initialize", payload);
await command("turn_end", {
...payload,
messages: [
{ role: "user", text: "Remember that I prefer tea." },
{ role: "assistant", text: "I will remember." },
],
});
assert.equal(modelCalls, 2);
assert.match(await readFile(join(root, "preference", "tea.md"), "utf8"), /prefers tea/);
},
);
3 changes: 2 additions & 1 deletion packages/memflywheel/src/host-memflywheel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import type { JsonObject } from "@earendil-works/pi-ai";

import {
attachMemFlywheelToHostPort,
Expand Down Expand Up @@ -164,7 +165,7 @@ function savingModel(): TestModelCompletion {
};
}

function toolCall(id: string, name: string, args: unknown) {
function toolCall(id: string, name: string, args: JsonObject) {
return { id, name, input: args };
}

Expand Down
8 changes: 6 additions & 2 deletions packages/memflywheel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ export {
export { type AdapterSpec, makeAdapter, readString, normalizeMessages } from "./make-adapter.js";

// Hermes installs its bridge worker outside the pnpm workspace. Re-export the
// pi-ai stream primitive through this already-resolved adapter entrypoint so
// pi-ai stream and transcript helpers through this already-resolved entrypoint so
// the installed worker has one dependency boundary instead of reaching back
// into workspace node_modules by package name.
export { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
export {
createAssistantMessageEventStream,
getCurrentSystemPrompt,
getCurrentTools,
} from "@earendil-works/pi-ai";

// Host harness port: stable host boundary + capability gates.
export {
Expand Down
Loading
Loading