Skip to content
Merged
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
48 changes: 48 additions & 0 deletions docs/platformclaw/web-ingress-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,54 @@ The runtime lives in `packages/platformclaw-control-plane`. It is intentionally
separate from OpenClaw `src/gateway/**` so upstream Gateway and Control UI
updates remain practical to merge.

## Composer Predictions

Composer Predictions is a Web-only opt-in in **Settings > Appearance > Chat preferences**.
It is off by default for both the server and each account. Enable the server gate
in `plugins.entries.admin-http-rpc.config.composerPredictionsEnabled`, then restart
the development Gateway. Each employee can enable or disable their own preference;
it persists in the plugin's SQLite keyed store without a schema-version change.

The browser waits one second after a completed response while the composer is
empty. It requests `/platformclaw/api/composer-predictions` using the employee
cookie and same-origin policy. The BFF reads history through the existing personal
or Space conversation authorization and checks access again after inference.
Predictions are returned only to that request; they are neither broadcast nor
written to conversation history. Caller-supplied user identities, context, model,
or endpoint parameters are rejected.

The trusted admin HTTP plugin calls only configured `codemate/codellm-fast`
(`ornith-1.5-35b` in the company catalog), using the existing OpenAI-compatible
provider URL, headers, and resolved credentials. The destination must resolve to
private or loopback addresses, DNS is pinned, and redirects are forbidden. Missing
configuration or unsupported API adapters yield no suggestion. No external LLM,
Agent Loop, tools, skills, memory retrieval, retries, or model fallback are used.
Normal conversation defaults and fallback settings remain unchanged.

Inference receives only the last six user/assistant text messages, each capped
at 300 Unicode characters. This bounded context supports two or three exchanges
without copying attachments, reasoning, tool output, or an unbounded transcript.
Output is capped at 64 tokens and 300 characters. The whole browser/BFF request
has a ten-second deadline, including Gateway history access. There is one active
prediction per Gateway process and no prediction queue. Ordinary model calls
preempt predictions. No vLLM load metrics are assumed; the single-slot policy is
the conservative admission control. A multi-Gateway deployment needs a shared
admission owner before enabling this feature across instances.

Tab inserts Ghost Text without sending. Escape, typing, IME composition, session
changes, and a new response invalidate it. Enter retains its existing behavior.
Cancellation closes the HTTP request to the model; actual GPU cancellation
depends on the company's serving stack and must be measured there. A server
that continues inference after disconnect can still consume up to the 64-token
budget. Real chat latency and GPU effects require a company-environment load test.

Knox, Cron, API adapters, and background runs do not trigger prediction inference.
The browser records a bounded run-ownership fact when it dispatches a message;
an unrelated final event in the same visible session does not qualify.
The private composer operation has no Gateway WebSocket method or agent tool.
Only the authenticated Web BFF may supply employee identity and approved context
through the existing trusted admin HTTP transport.

## Request flow

1. The browser signs in through the PlatformClaw authentication endpoints.
Expand Down
52 changes: 52 additions & 0 deletions docs/platformclaw/windows-main-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,55 @@ the primary checkout toolchain through repository wrappers or remote checks.
Close the three service windows to stop the local stack. Actual model replies
still require an approved OpenAI-compatible provider configuration; login,
session, provisioning, authorization, and UI checks do not.

## Composer Predictions fixture

From a normal feature checkout with dependencies ready, run:

```powershell
pnpm platformclaw:composer-preview
```

In a Windows linked worktree, reuse the primary checkout toolchain and run the
Node entry point directly:

```powershell
node --import tsx scripts/platformclaw-composer-preview.ts
```

Open `http://127.0.0.1:60326/platformclaw/app/chat/assigned-personal/main` in any
browser tab. This serves the real Web UI with synthetic chat and a clearly
marked development Mock. Every document receives the same fixture bootstrap;
closing a browser tab does not stop the server. Keep the command running until
manual testing finishes, then press Ctrl-C. The fixed loopback port must be free.
The fixture is test tooling and is excluded from the production application.

For an agent handoff on Windows, launch it independently of the command session:

```powershell
$previewRoot = (Get-Location).Path
$previewLogs = Join-Path $previewRoot '.artifacts/composer-predictions'
New-Item -ItemType Directory -Force -Path $previewLogs | Out-Null
$previewProcess = Start-Process -FilePath (Get-Command node).Source -ArgumentList @('--import', 'tsx', 'scripts/platformclaw-composer-preview.ts') -WorkingDirectory $previewRoot -WindowStyle Hidden -RedirectStandardOutput (Join-Path $previewLogs 'server.log') -RedirectStandardError (Join-Path $previewLogs 'server.err.log') -PassThru
$previewProcess.Id
```

Retain the returned process ID to stop this preview with `Stop-Process -Id <id>`.
Preferences and scenario selection belong to the single synthetic account for
this server process and reset when it restarts. No real Gateway or LLM runs.

Open **Settings > Appearance > Chat preferences**, enable **다음 프롬프트 예측**,
and return to Chat. Send any synthetic message. After the mock answer completes,
the next prompt appears in gray italic Ghost Text. Tab inserts it without sending.
Escape dismisses it. Typing and Korean IME composition invalidate it.

Use the Mock banner's scenario selector for immediate success, a four-second
model delay, a ten-second timeout without a suggestion, or a busy-model skip.
In the delay scenario, type or switch sessions before the reply arrives and
verify that no stale suggestion appears. Disable the account preference and send
another message to verify that no prediction request occurs.

The fixture proves interaction behavior only. It does not validate company Fast
availability, model quality, GPU cancellation, or normal-chat latency under real
load. See [Web ingress runtime](/platformclaw/web-ingress-runtime#composer-predictions)
for the actual model path, server flag, and operational limits.
136 changes: 134 additions & 2 deletions extensions/admin-http-rpc/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
// Admin Http Rpc tests cover index plugin behavior.
import { describe, expect, it } from "vitest";
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { PLATFORMCLAW_PRODUCT_SYSTEM_CONTEXT } from "./src/product-identity.js";

type PluginApi = Parameters<typeof plugin.register>[0];
const mocks = vi.hoisted(() => ({ fetch: vi.fn(), lookup: vi.fn() }));
vi.mock("node:dns/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:dns/promises")>()),
lookup: mocks.lookup,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>()),
fetchWithSsrFGuard: mocks.fetch,
}));

afterEach(() => {
vi.resetAllMocks();
});

describe("admin-http-rpc plugin entry", () => {
it("stays startup-off until the plugin entry is explicitly enabled", () => {
Expand Down Expand Up @@ -78,7 +92,12 @@ describe("admin-http-rpc plugin entry", () => {
{ method: "platformclaw.profile.seed", options: { scope: "operator.admin" } },
{ method: "platformclaw.profile.status", options: { scope: "operator.admin" } },
]);
expect(hooks).toEqual(["before_tool_call", "before_prompt_build"]);
expect(hooks).toEqual([
"model_call_started",
"model_call_ended",
"before_tool_call",
"before_prompt_build",
]);
const sharedAgent = "space-12345678-1234-1234-1234-123456789abc";
expect(beforeToolCall?.({ toolName: "exec" }, { agentId: sharedAgent })).toMatchObject({
block: true,
Expand All @@ -91,6 +110,11 @@ describe("admin-http-rpc plugin entry", () => {
).toBeUndefined();
expect(beforeToolCall?.({ toolName: "exec" }, { agentId: "person-alice" })).toBeUndefined();
expect(stores).toEqual([
{
namespace: "platformclaw.composer-preferences",
maxEntries: 50_000,
overflowPolicy: "reject-new",
},
{
namespace: "platformclaw.employee-profiles",
maxEntries: 50_000,
Expand Down Expand Up @@ -120,4 +144,112 @@ describe("admin-http-rpc plugin entry", () => {
prependContext: expect.stringContaining('"employeeId": "employee-1"'),
});
});

it("routes ordinary model lifecycle hooks to HTTP prediction admission", async () => {
const routes: Array<Parameters<PluginApi["registerHttpRoute"]>[0]> = [];
const hooks = new Map<string, (event: { callId: string }) => unknown>();
const stores = new Map<string, Map<string, unknown>>();
plugin.register({
runtime: {
config: {
current: () => ({
plugins: {
entries: { "admin-http-rpc": { config: { composerPredictionsEnabled: true } } },
},
models: {
providers: {
codemate: {
api: "openai-completions",
baseUrl: "http://company.internal/v1",
models: [{ id: "codellm-fast" }],
},
},
},
}),
},
state: {
openKeyedStore({ namespace }: { namespace: string }) {
const values = new Map<string, unknown>();
stores.set(namespace, values);
return {
lookup: async (key: string) => values.get(key),
register: async (key: string, value: unknown) => values.set(key, value),
};
},
},
},
registerHttpRoute: (route: Parameters<PluginApi["registerHttpRoute"]>[0]) =>
routes.push(route),
registerGatewayMethod: () => {},
on: (name: Parameters<PluginApi["on"]>[0], handler: unknown) =>
hooks.set(name, handler as (event: { callId: string }) => unknown),
} as unknown as PluginApi);
const route = routes[0];
if (!route) {
throw new Error("Admin HTTP route was not registered");
}
const server = createServer((req, res) => {
void Promise.resolve(route.handler(req, res)).catch(() => res.destroy());
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Admin HTTP test listener has no address");
}
const post = async (params: unknown) => {
const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/admin/rpc`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "platformclaw.composer", params }),
});
expect(response.ok).toBe(true);
return response.json();
};
await post({ action: "preferences", userId: "user-1", enabled: true });
expect(stores.get("platformclaw.composer-preferences")?.get("user-1")).toBe(true);
expect(stores.get("platformclaw.employee-profiles")?.has("user-1")).toBe(false);
mocks.lookup.mockResolvedValue([{ address: "10.1.2.3", family: 4 }]);
mocks.fetch.mockResolvedValue({
response: Response.json({ choices: [{ message: { content: "다음 단계는 무엇인가요?" } }] }),
release: async () => {},
});
let upstreamSignal: AbortSignal | undefined;
mocks.fetch.mockImplementationOnce(
({ signal }: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
upstreamSignal = signal;
signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}),
);
const params = {
action: "predict",
userId: "user-1",
messages: [
{ role: "user", content: "계획" },
{ role: "assistant", content: "답변" },
],
};
const prediction = post(params);
await vi.waitFor(() => expect(upstreamSignal).toBeDefined());
hooks.get("model_call_started")?.({ callId: "ordinary-chat" });
expect(upstreamSignal?.aborted).toBe(true);
expect(await prediction).toMatchObject({ ok: true, payload: { text: null } });
expect(await post(params)).toMatchObject({ ok: true, payload: { text: null } });
expect(mocks.fetch).toHaveBeenCalledTimes(1);
hooks.get("model_call_ended")?.({ callId: "ordinary-chat" });
expect(await post(params)).toMatchObject({
ok: true,
payload: { text: "다음 단계는 무엇인가요?" },
});
expect(mocks.fetch).toHaveBeenCalledTimes(2);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
8 changes: 7 additions & 1 deletion extensions/admin-http-rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
handleAgentConfigStatus,
PLATFORMCLAW_AGENT_CONFIG_STATUS_METHOD,
} from "./src/agent-runtime-status.js";
import { createComposerPredictions } from "./src/composer-predictions.js";
import {
handleEmployeeProfileSeed,
handleEmployeeProfileStatus,
Expand All @@ -28,6 +29,11 @@ export default definePluginEntry({
name: "Admin HTTP RPC",
description: "Expose selected admin RPC and PlatformClaw profile context",
register(api) {
const composer = createComposerPredictions(api);
// Ordinary model calls, including Knox and Cron, outrank speculative work.
// Keep lifecycle registration here; the service owns admission and cancellation.
api.on("model_call_started", (event) => composer.modelCallStarted(event.callId));
api.on("model_call_ended", (event) => composer.modelCallEnded(event.callId));
const employeeProfiles = api.runtime.state.openKeyedStore<unknown>({
namespace: PLATFORMCLAW_PROFILE_STORE_NAMESPACE,
maxEntries: MAX_EMPLOYEE_PROFILES,
Expand All @@ -48,7 +54,7 @@ export default definePluginEntry({
auth: "gateway",
match: "exact",
gatewayRuntimeScopeSurface: "trusted-operator",
handler: handleAdminHttpRpcRequest,
handler: (req, res) => handleAdminHttpRpcRequest(req, res, composer.handleRequest),
});
api.registerGatewayMethod(PLATFORMCLAW_AGENT_CONFIG_STATUS_METHOD, handleAgentConfigStatus, {
scope: "operator.admin",
Expand Down
8 changes: 7 additions & 1 deletion extensions/admin-http-rpc/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"composerPredictionsEnabled": {
"type": "boolean",
"default": false,
"description": "Enable Web-only, account-opt-in predictions using codemate/codellm-fast."
}
}
}
}
Loading
Loading