c8ctl-plugin-nano 1.30.0 → 1.31.0
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/README.md +85 -1
- package/c8ctl-plugin.js +194 -8
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -219,6 +219,66 @@ name is generated, so two `work reviewer` processes never collide at the
|
|
|
219
219
|
broker. (`--name` names the worker; the profile to run is always the
|
|
220
220
|
positional argument.)
|
|
221
221
|
|
|
222
|
+
### Live agent visibility: the `/agentic` channel + live terminals
|
|
223
|
+
|
|
224
|
+
A running worker can appear **live on the Workforce visibility page** and stream
|
|
225
|
+
its agents' terminals to an operator's cockpit. This rides the app's agentic
|
|
226
|
+
channel (ADR 0056), served **same-port** on the app's own HTTP base URL at path
|
|
227
|
+
**`/agentic`** — not a sidecar, so there's no extra port to open.
|
|
228
|
+
|
|
229
|
+
**Connecting.** Enrolment is opt-in: a worker only connects when it has both an
|
|
230
|
+
ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
|
|
231
|
+
pattern the blackboard uses). Point it at the app and hand it the two secrets via
|
|
232
|
+
env (or persisted config); the channel URL defaults to the configured nano URL:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# Enrol this worker on the app's same-port /agentic channel
|
|
236
|
+
export NANO_AGENTIC_URL=http://localhost:8080 # app base URL; channel is served at /agentic
|
|
237
|
+
export NANO_AGENTIC_TOKEN=<identity-token> # ADR 0028 identity
|
|
238
|
+
export NANO_AGENTIC_CREDENTIAL=<capability-cred> # capability credential
|
|
239
|
+
c8ctl nano work reviewer
|
|
240
|
+
# agentic channel: announcing presence as ‹worker› on ws://localhost:8080/agentic
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Without both secrets the worker runs **exactly as before, off the channel** — no
|
|
244
|
+
visibility, no relay, nothing else changes. A valid identity + capability
|
|
245
|
+
connects; an invalid identity is rejected (unauthorized) and a missing capability
|
|
246
|
+
is rejected (forbidden).
|
|
247
|
+
|
|
248
|
+
**How presence appears.** On connect the worker **announces** its identity, its
|
|
249
|
+
`host`, and the set of `jobKeys` it is currently running, then **heartbeats** to
|
|
250
|
+
stay live and **re-announces** after a reconnect so its row survives a hub
|
|
251
|
+
restart. When `work` stops it **deregisters cleanly**, so the worker disappears
|
|
252
|
+
from the visibility page on exit.
|
|
253
|
+
|
|
254
|
+
**Opting a role into a live terminal (PTY vs pipe).** Each role chooses whether
|
|
255
|
+
its agent harness runs on a full **PTY** (a real terminal — streamed on the relay
|
|
256
|
+
lane and **steerable**: an operator's keystrokes reach the running agent) or a
|
|
257
|
+
plain **pipe** (streamed, not interactive). It's a **per-role opt-in**, set on
|
|
258
|
+
the hire profile and defaulting to `pipe`:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Hire a role whose harness runs on a full, steerable PTY
|
|
262
|
+
c8ctl nano hire --name coder --rank senior --command copilot --terminal pty
|
|
263
|
+
|
|
264
|
+
# Override the mode for a one-off worker without re-hiring
|
|
265
|
+
NANO_AGENTIC_TERMINAL=pipe c8ctl nano work coder
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
A PTY needs the optional native `node-pty` dependency; when it is
|
|
269
|
+
**unavailable** (not installed, or Windows) a `pty` role **gracefully falls
|
|
270
|
+
back to a pipe** that still relays. (If `node-pty` is present but a PTY can't be
|
|
271
|
+
spawned at runtime, that job fails rather than falling back.) Each job's terminal
|
|
272
|
+
streams on its own relay stream
|
|
273
|
+
named `job:‹jobKey›`, so output and steer-in are routed from the `jobKey` alone.
|
|
274
|
+
|
|
275
|
+
**Surviving a hub outage.** A worker that starts **before** the app, or survives
|
|
276
|
+
a **hub restart**, buffers its outbound frames in a **bounded** local ring and
|
|
277
|
+
**drains them in order** on reconnect (no loss or reorder within the bound). The
|
|
278
|
+
bound is operator-tunable for a long expected outage; raise it with
|
|
279
|
+
`NANO_AGENTIC_BUFFER_CAPACITY` (frames). When the bound is hit the worker warns
|
|
280
|
+
rather than silently shedding.
|
|
281
|
+
|
|
222
282
|
### Live profile reload (no restart on `assign`)
|
|
223
283
|
|
|
224
284
|
A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
|
|
@@ -332,8 +392,32 @@ composed in FEEL, a task may supply per-instance context via **`task.appendPromp
|
|
|
332
392
|
separator/preamble), so a null/empty append leaves the base untouched. This lets the
|
|
333
393
|
static prompt live in a model header/side-car while the dynamic tail (e.g. plan-revision
|
|
334
394
|
feedback, a per-task brief) is built per instance.
|
|
395
|
+
|
|
396
|
+
**Live prompts via linked resources.** A service task can declare a Zeebe/Camunda-parity
|
|
397
|
+
**linked resource** for its prompt instead of baking it into a model header:
|
|
398
|
+
|
|
399
|
+
```xml
|
|
400
|
+
<zeebe:linkedResources>
|
|
401
|
+
<zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt"/>
|
|
402
|
+
</zeebe:linkedResources>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
At job activation the engine resolves the `resourceId` to the **latest** deployed key and
|
|
406
|
+
delivers a `linkedResources` custom header (`[{resourceKey, resourceType, linkName}]`). The
|
|
407
|
+
header carries the **key, not the content** — the worker fetches the bytes over the broker
|
|
408
|
+
REST API (`GET /v2/resources/{resourceKey}/content/binary`, reusing the same nano endpoint
|
|
409
|
+
the worker already talks to; override with `NANO_REST_URL`/`NANO_REST_TOKEN`) and uses the
|
|
410
|
+
UTF-8 content as the **base prompt**. The entry whose `linkName` is `prompt` wins over the
|
|
411
|
+
header-baked `task.prompt` chain; `appendPrompt` still composes onto it. Redeploying just
|
|
412
|
+
the resource updates the prompt for the **next activation** — no process redeploy, no
|
|
413
|
+
worker restart. Jobs without `linkedResources` behave exactly as before (fallback chain).
|
|
414
|
+
A declared prompt resource that can't be fetched **fails the job** (retryable provisioning
|
|
415
|
+
error) rather than running an agent with an empty prompt, and the resolved `resourceKey` is
|
|
416
|
+
logged and echoed on the output envelope as `promptResourceKey` for audit (the engine keeps
|
|
417
|
+
only `latest`, so the key is the reproducibility handle).
|
|
418
|
+
|
|
335
419
|
On completion the plugin writes an **output envelope** back under
|
|
336
|
-
`io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`). When a repository was
|
|
420
|
+
`io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error, promptResourceKey?}`). When a repository was
|
|
337
421
|
provisioned (below) it also carries `{repository, branch, baseSha, headSha, commits[], pushed, pushError?, gitError?, pr?}`.
|
|
338
422
|
|
|
339
423
|
**Git provisioning (host).** When `--sandbox none` (the default) and the envelope
|
package/c8ctl-plugin.js
CHANGED
|
@@ -2072,6 +2072,15 @@ function killTree(child) {
|
|
|
2072
2072
|
// envelope is written back on the job's completion variables.
|
|
2073
2073
|
const AGENT_TASK_NS = 'io.nanobpm.agentTask';
|
|
2074
2074
|
const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
|
|
2075
|
+
// Zeebe/Camunda-parity linked resources (issue #63). At job activation the
|
|
2076
|
+
// engine resolves each declared `<zeebe:linkedResource resourceId … linkName>`
|
|
2077
|
+
// to the LATEST deployed key and delivers this custom header: a JSON array of
|
|
2078
|
+
// `{ resourceKey, resourceType, linkName }`. The header carries the key, not the
|
|
2079
|
+
// content — the worker fetches the bytes over the broker REST API. The entry
|
|
2080
|
+
// whose `linkName` matches DEFAULT_PROMPT_LINK_NAME supplies the agent's base
|
|
2081
|
+
// prompt (live-updatable by redeploying just the resource).
|
|
2082
|
+
const LINKED_RESOURCES_HEADER = 'linkedResources';
|
|
2083
|
+
const DEFAULT_PROMPT_LINK_NAME = 'prompt';
|
|
2075
2084
|
const TASK_ENVELOPE_SCHEMA_VERSION = 1;
|
|
2076
2085
|
// The result-envelope version is intentionally independent of the task-envelope
|
|
2077
2086
|
// version so the two contracts can evolve separately without silently coupling.
|
|
@@ -2236,7 +2245,13 @@ function collectEnvelopeFrom(source) {
|
|
|
2236
2245
|
|
|
2237
2246
|
// Normalize the assembled envelope to schema v1, coercing header string values
|
|
2238
2247
|
// (element templates write everything as strings) into bool/int as needed.
|
|
2239
|
-
|
|
2248
|
+
//
|
|
2249
|
+
// `opts.basePromptOverride` (issue #63): when a `linkName: prompt` linked
|
|
2250
|
+
// resource resolves, its fetched content is passed here and WINS over the
|
|
2251
|
+
// header-baked `task.prompt` / `variables.prompt` / `variables.task` chain. The
|
|
2252
|
+
// FEEL-composed `appendPrompt` concatenation is unchanged — it still appends to
|
|
2253
|
+
// whatever base was resolved.
|
|
2254
|
+
function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
|
|
2240
2255
|
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
2241
2256
|
const str = (v) => (v == null ? undefined : String(v));
|
|
2242
2257
|
const env = {
|
|
@@ -2272,9 +2287,12 @@ function normalizeTaskEnvelope(customHeaders, variables) {
|
|
|
2272
2287
|
};
|
|
2273
2288
|
|
|
2274
2289
|
const task = isPlainObject(raw.task) ? raw.task : {};
|
|
2275
|
-
// Base prompt:
|
|
2276
|
-
// else
|
|
2277
|
-
|
|
2290
|
+
// Base prompt: an override supplied by a resolved `linkName: prompt` linked
|
|
2291
|
+
// resource (issue #63) wins; else the reserved `task.prompt` (typically a model
|
|
2292
|
+
// header filled at deploy time), else a plain `prompt`/`task` job variable (the
|
|
2293
|
+
// pre-header delivery path).
|
|
2294
|
+
const overrideBase = opts && opts.basePromptOverride != null ? String(opts.basePromptOverride) : undefined;
|
|
2295
|
+
const basePrompt = overrideBase ?? str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task);
|
|
2278
2296
|
// Verbatim dynamic append: a header-delivered base prompt can't be composed in FEEL, so a task
|
|
2279
2297
|
// may supply per-instance context (e.g. plan-revision feedback, a per-task brief) via the
|
|
2280
2298
|
// reserved `task.appendPrompt`, or a plain `appendPrompt` variable. It is concatenated onto the
|
|
@@ -2297,7 +2315,133 @@ function normalizeTaskEnvelope(customHeaders, variables) {
|
|
|
2297
2315
|
return env;
|
|
2298
2316
|
}
|
|
2299
2317
|
|
|
2300
|
-
// ----
|
|
2318
|
+
// ---- Linked resources → live agent prompt (issue #63) ----------------------
|
|
2319
|
+
// A job's `linkedResources` activation header carries KEYS (not content); the
|
|
2320
|
+
// worker resolves each to the LATEST deployed bytes over the broker REST API, so
|
|
2321
|
+
// an agent prompt can be updated by redeploying one Markdown resource — no
|
|
2322
|
+
// process redeploy, no harness restart.
|
|
2323
|
+
|
|
2324
|
+
// Parse the `linkedResources` custom header off an activated job. The engine
|
|
2325
|
+
// delivers a JSON array; element-template/header transport stringifies it. Also
|
|
2326
|
+
// tolerate an already-parsed array. Anything malformed/absent → [] (fallback
|
|
2327
|
+
// path). Never throws.
|
|
2328
|
+
function parseLinkedResources(customHeaders) {
|
|
2329
|
+
if (!isPlainObject(customHeaders)) return [];
|
|
2330
|
+
const raw = customHeaders[LINKED_RESOURCES_HEADER];
|
|
2331
|
+
if (raw == null) return [];
|
|
2332
|
+
let val = raw;
|
|
2333
|
+
if (typeof raw === 'string') {
|
|
2334
|
+
if (!raw.trim()) return [];
|
|
2335
|
+
try { val = JSON.parse(raw); } catch { return []; }
|
|
2336
|
+
}
|
|
2337
|
+
if (!Array.isArray(val)) return [];
|
|
2338
|
+
return val.filter((e) => isPlainObject(e) && e.resourceKey != null);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// Select the linked resource that supplies the base prompt: the first entry
|
|
2342
|
+
// whose `linkName` matches (default `prompt`). Returns the entry or null.
|
|
2343
|
+
function pickLinkedResource(linkedResources, linkName = DEFAULT_PROMPT_LINK_NAME) {
|
|
2344
|
+
if (!Array.isArray(linkedResources)) return null;
|
|
2345
|
+
return linkedResources.find((e) => isPlainObject(e) && String(e.linkName) === String(linkName)) || null;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// The broker REST base URL + optional bearer the harness uses to fetch resource
|
|
2349
|
+
// content — the SAME nano endpoint the worker already talks to (env wins over
|
|
2350
|
+
// persisted config, falling back to the default localhost port). A local nano
|
|
2351
|
+
// cluster is unauthenticated, so the token is optional; when set (e.g. against a
|
|
2352
|
+
// secured gateway) it is sent as a Bearer credential.
|
|
2353
|
+
// Same-origin test for two URLs (protocol + host + port). Returns false if
|
|
2354
|
+
// either string is missing or unparseable, so a token is never forwarded to an
|
|
2355
|
+
// endpoint we can't positively confirm matches.
|
|
2356
|
+
function sameOrigin(a, b) {
|
|
2357
|
+
if (!a || !b) return false;
|
|
2358
|
+
try {
|
|
2359
|
+
return new URL(a).origin === new URL(b).origin;
|
|
2360
|
+
} catch {
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function resolveBrokerRestConfig(env = process.env) {
|
|
2366
|
+
// readConfig() swallows parse/IO errors and never throws (returns {}), so no
|
|
2367
|
+
// local try/catch is needed here.
|
|
2368
|
+
const cfg = readConfig() || {};
|
|
2369
|
+
const baseUrl =
|
|
2370
|
+
env.NANO_REST_URL ||
|
|
2371
|
+
env.NANO_BASE_URL ||
|
|
2372
|
+
cfg.nanoUrl ||
|
|
2373
|
+
DEFAULT_NANO_URL;
|
|
2374
|
+
// An explicit REST token always wins. The agentic identity token is only a
|
|
2375
|
+
// fallback for single-token deployments where the broker REST endpoint IS the
|
|
2376
|
+
// agentic endpoint — so only forward it when the REST base URL is same-origin
|
|
2377
|
+
// as the agentic URL. This prevents leaking the identity token to a different
|
|
2378
|
+
// NANO_REST_URL host when no REST token is set (see resolveAgenticConfig).
|
|
2379
|
+
let token = env.NANO_REST_TOKEN || '';
|
|
2380
|
+
if (!token) {
|
|
2381
|
+
const agenticToken = env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
|
|
2382
|
+
const agenticUrl =
|
|
2383
|
+
env.NANO_AGENTIC_URL ||
|
|
2384
|
+
cfg.agenticUrl ||
|
|
2385
|
+
cfg.nanoUrl ||
|
|
2386
|
+
env.NANO_BASE_URL ||
|
|
2387
|
+
DEFAULT_NANO_URL;
|
|
2388
|
+
if (agenticToken && sameOrigin(baseUrl, agenticUrl)) token = agenticToken;
|
|
2389
|
+
}
|
|
2390
|
+
return { baseUrl, token };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Build the content endpoint. Per issue #63 / nano-bpm #759 the non-binary
|
|
2394
|
+
// `/content` variant is deprecated for non-RPA types (Markdown → 406), so the
|
|
2395
|
+
// worker always fetches `/content/binary`.
|
|
2396
|
+
function resourceContentUrl(baseUrl, resourceKey) {
|
|
2397
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
2398
|
+
return `${base}/v2/resources/${encodeURIComponent(String(resourceKey))}/content/binary`;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Fetch a linked resource's bytes and decode UTF-8. A non-2xx or network error
|
|
2402
|
+
// is surfaced as a ProvisionError so the caller fails the job with a clear
|
|
2403
|
+
// provisioning message (never runs an agent with a silently-empty prompt).
|
|
2404
|
+
async function fetchLinkedResourceContent(resourceKey, opts = {}) {
|
|
2405
|
+
const { baseUrl, token, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
|
|
2406
|
+
const url = resourceContentUrl(baseUrl, resourceKey);
|
|
2407
|
+
const controller = new AbortController();
|
|
2408
|
+
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
2409
|
+
let res;
|
|
2410
|
+
try {
|
|
2411
|
+
const headers = { Accept: 'application/octet-stream' };
|
|
2412
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
2413
|
+
res = await fetchImpl(url, { method: 'GET', headers, signal: controller.signal });
|
|
2414
|
+
} catch (err) {
|
|
2415
|
+
throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: ${err && err.message ? err.message : String(err)}`);
|
|
2416
|
+
} finally {
|
|
2417
|
+
if (timer) clearTimeout(timer);
|
|
2418
|
+
}
|
|
2419
|
+
if (!res || !res.ok) {
|
|
2420
|
+
const status = res ? res.status : '?';
|
|
2421
|
+
throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: HTTP ${status} from ${url}`);
|
|
2422
|
+
}
|
|
2423
|
+
try {
|
|
2424
|
+
return await res.text();
|
|
2425
|
+
} catch (err) {
|
|
2426
|
+
throw new ProvisionError(`prompt resource ${resourceKey} decode failed: ${err && err.message ? err.message : String(err)}`);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Resolve the base prompt from a `linkName: prompt` linked resource, if the job
|
|
2431
|
+
// declares one. Returns `{ basePrompt, resourceKey }` when a prompt resource is
|
|
2432
|
+
// present and fetched, or null when no such entry exists (→ fall back to the
|
|
2433
|
+
// header-baked task.prompt chain). Throws a ProvisionError when a declared
|
|
2434
|
+
// prompt resource cannot be fetched — a provisioning failure, not a silent
|
|
2435
|
+
// empty-prompt run.
|
|
2436
|
+
async function resolveLinkedPrompt(customHeaders, opts = {}) {
|
|
2437
|
+
const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, fetchImpl, timeoutMs } = opts;
|
|
2438
|
+
const entry = pickLinkedResource(parseLinkedResources(customHeaders), linkName);
|
|
2439
|
+
if (!entry) return null;
|
|
2440
|
+
const resourceKey = entry.resourceKey;
|
|
2441
|
+
const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, fetchImpl, timeoutMs });
|
|
2442
|
+
return { basePrompt, resourceKey, resourceType: entry.resourceType ?? null, linkName: String(linkName) };
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2301
2445
|
// Secrets are referenced by NAME, never by value, in the model. A resolver maps
|
|
2302
2446
|
// a name → value at run time. The wrapper injects them into the child ENV, so
|
|
2303
2447
|
// values never appear in argv or `docker inspect`.
|
|
@@ -3416,7 +3560,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3416
3560
|
|
|
3417
3561
|
// Shape the io.nanobpm.agentResult output envelope. When a repository was
|
|
3418
3562
|
// provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
|
|
3419
|
-
function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
|
|
3563
|
+
function buildResultEnvelope(result, { sandbox, image, git, result: agentResult, promptResourceKey } = {}) {
|
|
3420
3564
|
const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
|
|
3421
3565
|
const env = {
|
|
3422
3566
|
schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
|
|
@@ -3430,6 +3574,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
|
|
|
3430
3574
|
signal: result.signal ?? null,
|
|
3431
3575
|
error: result.error ?? null,
|
|
3432
3576
|
};
|
|
3577
|
+
// Audit (issue #63): record which linked-resource key supplied the base prompt.
|
|
3578
|
+
// The engine only keeps `latest` per resourceId (no pinning), so recording the
|
|
3579
|
+
// resolved key is the only reproducibility handle for which prompt version ran.
|
|
3580
|
+
if (promptResourceKey != null) env.promptResourceKey = String(promptResourceKey);
|
|
3433
3581
|
// The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
|
|
3434
3582
|
// preserved verbatim for auditability even when merged into the completion vars.
|
|
3435
3583
|
if (isPlainObject(agentResult)) env.result = agentResult;
|
|
@@ -3685,6 +3833,10 @@ async function workAgent(req, flags) {
|
|
|
3685
3833
|
const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
|
|
3686
3834
|
const camunda = globalThis.c8ctl.createClient();
|
|
3687
3835
|
|
|
3836
|
+
// Broker REST endpoint for live linked-resource prompts (issue #63) — the same
|
|
3837
|
+
// nano endpoint this worker already talks to. Resolved once at startup.
|
|
3838
|
+
const restConfig = resolveBrokerRestConfig();
|
|
3839
|
+
|
|
3688
3840
|
logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
3689
3841
|
logger.info(` worker: ${workerName}`);
|
|
3690
3842
|
logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
@@ -3853,9 +4005,33 @@ async function workAgent(req, flags) {
|
|
|
3853
4005
|
}
|
|
3854
4006
|
}
|
|
3855
4007
|
|
|
4008
|
+
// Live agent prompt (issue #63): if the job declares a `linkName: prompt`
|
|
4009
|
+
// linked resource, fetch its LATEST deployed content and use it as the
|
|
4010
|
+
// base prompt (it wins over the header-baked task.prompt). A declared
|
|
4011
|
+
// prompt resource that can't be fetched is a provisioning failure — fail
|
|
4012
|
+
// (retryable) rather than run an agent with an empty prompt.
|
|
4013
|
+
let promptResourceKey = null;
|
|
4014
|
+
let basePromptOverride;
|
|
4015
|
+
try {
|
|
4016
|
+
const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
|
|
4017
|
+
baseUrl: restConfig.baseUrl,
|
|
4018
|
+
token: restConfig.token,
|
|
4019
|
+
});
|
|
4020
|
+
if (linked) {
|
|
4021
|
+
basePromptOverride = linked.basePrompt;
|
|
4022
|
+
promptResourceKey = linked.resourceKey;
|
|
4023
|
+
logger.info(`[${jobType}] job ${job.jobKey} base prompt from linked resource key ${promptResourceKey} (linkName=${linked.linkName}, ${Buffer.byteLength(String(basePromptOverride), 'utf8')} bytes)`);
|
|
4024
|
+
}
|
|
4025
|
+
} catch (err) {
|
|
4026
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
4027
|
+
const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
|
|
4028
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
4029
|
+
return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
4030
|
+
}
|
|
4031
|
+
|
|
3856
4032
|
// Assemble + normalize the task envelope from headers (defaults) and
|
|
3857
4033
|
// variables (overrides), then resolve any secrets it references.
|
|
3858
|
-
const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
|
|
4034
|
+
const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {}, { basePromptOverride });
|
|
3859
4035
|
const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
|
|
3860
4036
|
if (missing.length > 0) {
|
|
3861
4037
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
@@ -4000,7 +4176,7 @@ async function workAgent(req, flags) {
|
|
|
4000
4176
|
if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
|
|
4001
4177
|
const resultVars = sanitizeResultVars(rawResult);
|
|
4002
4178
|
|
|
4003
|
-
const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
|
|
4179
|
+
const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult, promptResourceKey });
|
|
4004
4180
|
if (result.ok) {
|
|
4005
4181
|
const gitNote = gitResult
|
|
4006
4182
|
? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
|
|
@@ -6894,6 +7070,12 @@ export {
|
|
|
6894
7070
|
export {
|
|
6895
7071
|
normalizeTaskEnvelope,
|
|
6896
7072
|
collectEnvelopeFrom,
|
|
7073
|
+
parseLinkedResources,
|
|
7074
|
+
pickLinkedResource,
|
|
7075
|
+
resolveBrokerRestConfig,
|
|
7076
|
+
resourceContentUrl,
|
|
7077
|
+
fetchLinkedResourceContent,
|
|
7078
|
+
resolveLinkedPrompt,
|
|
6897
7079
|
coerceBool,
|
|
6898
7080
|
coerceInt,
|
|
6899
7081
|
deepMerge,
|
|
@@ -6940,6 +7122,8 @@ export {
|
|
|
6940
7122
|
derivePollTimeoutMs,
|
|
6941
7123
|
AGENT_TASK_NS,
|
|
6942
7124
|
AGENT_RESULT_KEY,
|
|
7125
|
+
LINKED_RESOURCES_HEADER,
|
|
7126
|
+
DEFAULT_PROMPT_LINK_NAME,
|
|
6943
7127
|
RESULT_SENTINEL,
|
|
6944
7128
|
RESERVED_RESULT_KEYS,
|
|
6945
7129
|
SANDBOXES,
|
|
@@ -7013,9 +7197,11 @@ export const metadata = {
|
|
|
7013
7197
|
{ command: 'c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1', description: 'Persist a harness startup env var (e.g. permissions) on the profile' },
|
|
7014
7198
|
{ command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
|
|
7015
7199
|
{ command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
|
|
7200
|
+
{ command: 'c8ctl nano hire --name coder --rank senior --command copilot --terminal pty', description: 'Opt this role into a full, steerable live terminal (PTY) streamed on the agentic relay lane (default: pipe)' },
|
|
7016
7201
|
{ command: 'c8ctl nano assign reviewer code-review,testing', description: 'Grant more capabilities (comma-separated, like hire) to an existing hire — additive; running workers hot-reload it' },
|
|
7017
7202
|
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
7018
7203
|
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
7204
|
+
{ command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_TOKEN=<identity-token> NANO_AGENTIC_CREDENTIAL=<capability-cred> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel so it appears live (presence + relay terminals) on the Workforce visibility page' },
|
|
7019
7205
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
7020
7206
|
{ command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
|
|
7021
7207
|
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"node-pty": "^1.0.0",
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.31.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.31.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.31.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.31.0",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.31.0",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.31.0",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.31.0"
|
|
67
67
|
}
|
|
68
68
|
}
|