@runuai/host 0.9.90 → 0.9.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agents/factory.ts +1 -0
- package/lib/agents/types.ts +3 -0
- package/lib/agents/uai.ts +87 -0
- package/lib/machine-provider-aws.ts +15 -0
- package/lib/vnode-pressure.ts +50 -13
- package/package.json +1 -1
- package/src/main.ts +3 -0
- package/src/protocol.ts +2 -0
package/lib/agents/factory.ts
CHANGED
package/lib/agents/types.ts
CHANGED
|
@@ -54,6 +54,9 @@ export const RosterAgent = z.object({
|
|
|
54
54
|
initialPrompt: z.string().optional(), // first-turn instructions, this task only
|
|
55
55
|
skills: z.array(RosterSkill).optional(), // ADR-046 reference material
|
|
56
56
|
permissions: z.array(z.string()).optional(), // ADR-048 `uai` CLI capabilities
|
|
57
|
+
// ADR-128: which upstream protocol a Uai-engine model speaks — resolved
|
|
58
|
+
// cloud-side from the model catalog; selects the delegated adapter.
|
|
59
|
+
uaiProvider: z.enum(["anthropic", "openai"]).optional(),
|
|
57
60
|
});
|
|
58
61
|
export type RosterAgent = z.infer<typeof RosterAgent>;
|
|
59
62
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Uai Managed Engine (ADR-128) — not a harness, a delegation.
|
|
3
|
+
*
|
|
4
|
+
* The customer picks "Uai Managed Engine" and one of Uai's models; under
|
|
5
|
+
* the hood this adapter spawns the EXISTING claude or codex adapter
|
|
6
|
+
* (chosen by the model's provider, resolved cloud-side into
|
|
7
|
+
* `agent.uaiProvider`) with the environment pointed at the inference
|
|
8
|
+
* gateway: base URL swapped, the agent's own task token as the API
|
|
9
|
+
* credential, and the host's real provider credentials explicitly
|
|
10
|
+
* BLANKED so a BYO host's Claude subscription can never be billed for a
|
|
11
|
+
* Uai-engine turn. The gateway meters usage server-side and the cloud
|
|
12
|
+
* debits credits — this file never sees money.
|
|
13
|
+
*
|
|
14
|
+
* The model slug passes through verbatim: the underlying CLI sends it as
|
|
15
|
+
* the request's `model`, and the gateway maps slug → upstream id.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { factoryFor, register } from "./registry";
|
|
19
|
+
import type { AgentSession, AgentSessionFactory } from "./types";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_GATEWAY_URL = "https://inference.runuai.com";
|
|
22
|
+
|
|
23
|
+
export function gatewayUrl(): string {
|
|
24
|
+
return process.env.UAI_INFERENCE_GATEWAY_URL || DEFAULT_GATEWAY_URL;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The env override set for a delegated session. Exported for tests. */
|
|
28
|
+
export function uaiGatewayEnv(
|
|
29
|
+
provider: "anthropic" | "openai",
|
|
30
|
+
url: string,
|
|
31
|
+
taskToken: string,
|
|
32
|
+
): Record<string, string> {
|
|
33
|
+
if (provider === "openai") {
|
|
34
|
+
return {
|
|
35
|
+
OPENAI_BASE_URL: url,
|
|
36
|
+
OPENAI_API_KEY: taskToken,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
ANTHROPIC_BASE_URL: url,
|
|
41
|
+
ANTHROPIC_AUTH_TOKEN: taskToken,
|
|
42
|
+
// Explicit blanks WIN over the claude adapter's passEnv inheritance
|
|
43
|
+
// (same mechanism its communicator profile uses to scrub) — the host's
|
|
44
|
+
// real credential must never ride a gateway-billed session.
|
|
45
|
+
ANTHROPIC_API_KEY: "",
|
|
46
|
+
CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const create: AgentSessionFactory["create"] = async (args): Promise<AgentSession> => {
|
|
51
|
+
const provider = args.agent.uaiProvider === "openai" ? "openai" : "anthropic";
|
|
52
|
+
const delegateKind = provider === "openai" ? "codex" : "claude";
|
|
53
|
+
const delegate = factoryFor(delegateKind);
|
|
54
|
+
if (!delegate) {
|
|
55
|
+
throw new Error(`uai engine: no ${delegateKind} adapter registered to delegate to`);
|
|
56
|
+
}
|
|
57
|
+
const taskToken = args.agentEnv?.UAI_TASK_TOKEN;
|
|
58
|
+
if (!taskToken) {
|
|
59
|
+
// agentCliEnv mints no token for a permissionless non-secretary agent;
|
|
60
|
+
// the gateway authenticates with exactly that token, so the engine
|
|
61
|
+
// needs it. The default crew grants permissions, so this is rare.
|
|
62
|
+
throw new Error(
|
|
63
|
+
"uai engine: this agent has no task token (no CLI permissions) — grant it at least one permission or use a direct engine",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return delegate.create({
|
|
67
|
+
...args,
|
|
68
|
+
agentEnv: {
|
|
69
|
+
...(args.agentEnv ?? {}),
|
|
70
|
+
...uaiGatewayEnv(provider, gatewayUrl(), taskToken),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
register({
|
|
76
|
+
kind: "uai",
|
|
77
|
+
label: "Uai Managed Engine",
|
|
78
|
+
// The model catalog is cloud data (ADR-128); the composer sources it from
|
|
79
|
+
// /api/org/inference/models, never from host capabilities. Advertising an
|
|
80
|
+
// empty list here simply marks the kind spawnable on this host.
|
|
81
|
+
supportedModels: () => [],
|
|
82
|
+
supportedEfforts: () => [],
|
|
83
|
+
// Always available: the engine needs no host-side credential — auth is
|
|
84
|
+
// the per-agent task token, billing is the org's credits.
|
|
85
|
+
available: () => true,
|
|
86
|
+
create,
|
|
87
|
+
});
|
|
@@ -136,6 +136,21 @@ export function awsUserData(authorizedPublicKey: string): string {
|
|
|
136
136
|
' chmod 700 "$home/.ssh"',
|
|
137
137
|
' chmod 600 "$home/.ssh/authorized_keys"',
|
|
138
138
|
"done",
|
|
139
|
+
// Swap BEFORE any workload: an Opus agent + a bundler + Chromium on a
|
|
140
|
+
// swapless machine hits the memory wall as a thrash (load 14, RAM 100%,
|
|
141
|
+
// sshd/code-server accept loops starved — live 2026-08-31 on the first
|
|
142
|
+
// real machine task; the HOSTS got their swapfile in 0.9.85, the
|
|
143
|
+
// machines never did). Best-effort: a machine without fallocate still
|
|
144
|
+
// boots.
|
|
145
|
+
"(fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab) || true",
|
|
146
|
+
// The task OWNS its machine (ADR-121 — per-customer kernel isolation is
|
|
147
|
+
// the whole point); denying sudo just breaks project setups that
|
|
148
|
+
// container tasks handle fine (live 2026-08-31: a Hightop machine task
|
|
149
|
+
// could not install the Infisical CLI its repo's config expects).
|
|
150
|
+
"echo 'node ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/node && chmod 440 /etc/sudoers.d/node",
|
|
151
|
+
// Infisical CLI: baseline parity with the standard container image —
|
|
152
|
+
// pnpm-secrets-style project setups expect it on PATH. Best-effort.
|
|
153
|
+
"(V=0.43.110; case \$(uname -m) in aarch64|arm64) T=arm64;; *) T=amd64;; esac; curl -fsSL -o /tmp/inf.tgz https://github.com/Infisical/cli/releases/download/v\${V}/cli_\${V}_linux_\${T}.tar.gz && tar -xzf /tmp/inf.tgz -C /tmp infisical && install -m 0755 /tmp/infisical /usr/local/bin/infisical) || true",
|
|
139
154
|
"",
|
|
140
155
|
].join("\n");
|
|
141
156
|
// RAW script, not base64: AWS CLI v2 base64-encodes --user-data ITSELF.
|
package/lib/vnode-pressure.ts
CHANGED
|
@@ -30,12 +30,25 @@ const exec = promisify(execFile);
|
|
|
30
30
|
const ADMISSION_THRESHOLD = 0.9;
|
|
31
31
|
/** Warn-log above this fraction. */
|
|
32
32
|
const WARN_THRESHOLD = 0.8;
|
|
33
|
+
/**
|
|
34
|
+
* The vnode counter ALONE cannot refuse: `kern.num_vnodes` includes the
|
|
35
|
+
* reclaimable vnode cache, and a healthy long-uptime Mac converges to
|
|
36
|
+
* num_vnodes == maxvnodes forever (live false-positive, 2026-08-31: the
|
|
37
|
+
* Studio refused every task at "100%" with ZERO VMs running and the file
|
|
38
|
+
* table at 1.2%). Real virtiofs pinning holds OPEN FILES — the Aug-24
|
|
39
|
+
* incident read 96% of `kern.num_files/kern.maxfiles`. So refusal
|
|
40
|
+
* requires the vnode ratio AND genuine file-table pressure together.
|
|
41
|
+
*/
|
|
42
|
+
const FILE_TABLE_CORROBORATION_THRESHOLD = 0.5;
|
|
33
43
|
const CACHE_MS = 15_000;
|
|
34
44
|
|
|
35
45
|
export interface VnodePressure {
|
|
36
46
|
current: number;
|
|
37
47
|
max: number;
|
|
38
48
|
ratio: number;
|
|
49
|
+
filesCurrent: number;
|
|
50
|
+
filesMax: number;
|
|
51
|
+
filesRatio: number;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
let cached: { at: number; value: VnodePressure | null } | null = null;
|
|
@@ -47,14 +60,30 @@ export async function readVnodePressure(): Promise<VnodePressure | null> {
|
|
|
47
60
|
try {
|
|
48
61
|
const { stdout } = await exec(
|
|
49
62
|
"/usr/sbin/sysctl",
|
|
50
|
-
["-n", "kern.num_vnodes", "kern.maxvnodes"],
|
|
63
|
+
["-n", "kern.num_vnodes", "kern.maxvnodes", "kern.num_files", "kern.maxfiles"],
|
|
51
64
|
{ timeout: 5_000 },
|
|
52
65
|
);
|
|
53
66
|
const parts = stdout.trim().split(/\s+/).map(Number);
|
|
54
67
|
const current = parts[0] ?? Number.NaN;
|
|
55
68
|
const max = parts[1] ?? Number.NaN;
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
const filesCurrent = parts[2] ?? Number.NaN;
|
|
70
|
+
const filesMax = parts[3] ?? Number.NaN;
|
|
71
|
+
if (
|
|
72
|
+
Number.isFinite(current) &&
|
|
73
|
+
Number.isFinite(max) &&
|
|
74
|
+
max > 0 &&
|
|
75
|
+
Number.isFinite(filesCurrent) &&
|
|
76
|
+
Number.isFinite(filesMax) &&
|
|
77
|
+
filesMax > 0
|
|
78
|
+
) {
|
|
79
|
+
value = {
|
|
80
|
+
current,
|
|
81
|
+
max,
|
|
82
|
+
ratio: current / max,
|
|
83
|
+
filesCurrent,
|
|
84
|
+
filesMax,
|
|
85
|
+
filesRatio: filesCurrent / filesMax,
|
|
86
|
+
};
|
|
58
87
|
}
|
|
59
88
|
} catch {
|
|
60
89
|
// Sensor failure is never a task failure; admission simply has no data.
|
|
@@ -67,11 +96,15 @@ export async function readVnodePressure(): Promise<VnodePressure | null> {
|
|
|
67
96
|
export async function vnodeAdmissionProblem(): Promise<string | null> {
|
|
68
97
|
const pressure = await readVnodePressure();
|
|
69
98
|
if (!pressure || pressure.ratio < ADMISSION_THRESHOLD) return null;
|
|
99
|
+
// A full vnode table with an idle file table is a warm cache, not danger.
|
|
100
|
+
if (pressure.filesRatio < FILE_TABLE_CORROBORATION_THRESHOLD) return null;
|
|
70
101
|
return (
|
|
71
102
|
`the macOS vnode table is at ${Math.round(pressure.ratio * 100)}% ` +
|
|
72
|
-
`(${pressure.current}/${pressure.max})
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
`(${pressure.current}/${pressure.max}) with the file table at ` +
|
|
104
|
+
`${Math.round(pressure.filesRatio * 100)}% ` +
|
|
105
|
+
`(${pressure.filesCurrent}/${pressure.filesMax}); starting another task ` +
|
|
106
|
+
"VM risks system-wide file-table exhaustion. Close a running task, or " +
|
|
107
|
+
"raise the ceiling once with: sudo uai-host tune"
|
|
75
108
|
);
|
|
76
109
|
}
|
|
77
110
|
|
|
@@ -82,15 +115,19 @@ let lastLoggedBand: "ok" | "warn" | "critical" = "ok";
|
|
|
82
115
|
export async function logVnodePressure(): Promise<void> {
|
|
83
116
|
const pressure = await readVnodePressure();
|
|
84
117
|
if (!pressure) return;
|
|
85
|
-
const
|
|
86
|
-
pressure.ratio >= ADMISSION_THRESHOLD
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
const refusing =
|
|
119
|
+
pressure.ratio >= ADMISSION_THRESHOLD &&
|
|
120
|
+
pressure.filesRatio >= FILE_TABLE_CORROBORATION_THRESHOLD;
|
|
121
|
+
const band = refusing
|
|
122
|
+
? "critical"
|
|
123
|
+
: pressure.filesRatio >= WARN_THRESHOLD || pressure.ratio >= WARN_THRESHOLD
|
|
124
|
+
? "warn"
|
|
125
|
+
: "ok";
|
|
91
126
|
if (band === lastLoggedBand) return;
|
|
92
127
|
lastLoggedBand = band;
|
|
93
|
-
const detail =
|
|
128
|
+
const detail =
|
|
129
|
+
`vnodes ${pressure.current}/${pressure.max} (${Math.round(pressure.ratio * 100)}%), ` +
|
|
130
|
+
`files ${pressure.filesCurrent}/${pressure.filesMax} (${Math.round(pressure.filesRatio * 100)}%)`;
|
|
94
131
|
if (band === "critical") {
|
|
95
132
|
console.warn(
|
|
96
133
|
`[vnode] table at ${detail} — new apple task VMs will be refused; run: sudo uai-host tune`,
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -2919,6 +2919,9 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
|
|
|
2919
2919
|
// effort was previously dropped here even though the protocol + adapters
|
|
2920
2920
|
// carry it — fixed alongside skills (ADR-046).
|
|
2921
2921
|
if (typeof row.effort === "string") out.effort = row.effort;
|
|
2922
|
+
if (row.uaiProvider === "anthropic" || row.uaiProvider === "openai") {
|
|
2923
|
+
out.uaiProvider = row.uaiProvider;
|
|
2924
|
+
}
|
|
2922
2925
|
if (typeof row.defaultPrompt === "string") {
|
|
2923
2926
|
out.defaultPrompt = row.defaultPrompt;
|
|
2924
2927
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -173,6 +173,8 @@ export type PermissionDecision = { kind: "accept" } | { kind: "decline" };
|
|
|
173
173
|
* `effort` (when set) is the CLI reasoning level, passed through like `model`.
|
|
174
174
|
*/
|
|
175
175
|
export interface TaskAgent {
|
|
176
|
+
/** ADR-128: upstream protocol of a Uai-engine model (kind "uai"). */
|
|
177
|
+
uaiProvider?: "anthropic" | "openai";
|
|
176
178
|
id: string;
|
|
177
179
|
label: string;
|
|
178
180
|
kind: string;
|