mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
feat(skills): nc:operator — delineate operator-facing content from agent prose
The SKILL.md is addressed to the coding agent (the interpreter), so its prose is
agent-facing by default. The parts meant for the HUMAN operator — clicking
through the Slack UI — were undifferentiated prose, so an agent couldn't tell
"relay this to the user" from "do this yourself", and the engine couldn't render
them as human steps.
nc:operator is the output twin of nc:prompt (prompt: ask the operator for input;
operator: give the operator instructions). Lead it with agent-facing prose like
"Tell the user:" so the agent relays it. The engine renders the body via the
Prompter's new optional tell() (a clack note in setup, a channel message when an
agent relays; absent in a headless rebuild → skipped). {{vars}} substitute in, so
a resolved value can be shown to the operator.
The content model is now: agent prose (unmarked, the degrade floor) / execution
directives / operator I/O (prompt in, operator out). add-slack's app-creation and
event-delivery walkthroughs are now nc:operator blocks; strip the fences and each
reads "Tell the user: <steps>" — natural, never narrating the engine.
Dry-run on the real skill: the two operator blocks route to tell() (the human),
the ncl commands to exec() (the agent) — cleanly separated. 600 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -332,3 +332,32 @@ describe('nc:run capture', () => {
|
||||
expect(validate(parseDirectives(CAPTURE_SKILL))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// operator: the parts addressed to the human (UI steps), delineated so the agent
|
||||
// relays them and the engine renders them — the output twin of prompt.
|
||||
describe('nc:operator', () => {
|
||||
let oroot: string;
|
||||
let oskill: string;
|
||||
beforeEach(() => {
|
||||
oskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
|
||||
oroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
|
||||
writeFileSync(join(oroot, 'package.json'), '{"name":"scratch"}');
|
||||
});
|
||||
|
||||
it('relays the operator body to prompter.tell, substituting {{vars}}', async () => {
|
||||
writeFileSync(
|
||||
join(oskill, 'SKILL.md'),
|
||||
'# op demo\n\n```nc:prompt who\nName?\n```\nTell the user:\n```nc:operator\nHello {{who}} — go click the button.\n```\n',
|
||||
);
|
||||
const told: string[] = [];
|
||||
const prompter: Prompter = { async ask() { return 'world'; }, tell: (t) => void told.push(t) };
|
||||
await applySkill(oskill, oroot, { prompter, exec: () => {} });
|
||||
expect(told).toEqual(['Hello world — go click the button.']);
|
||||
});
|
||||
|
||||
it('is a no-op when no operator sink is present (headless rebuild) — not a crash, not an agent bounce', async () => {
|
||||
writeFileSync(join(oskill, 'SKILL.md'), '# op demo\n\nTell the user:\n```nc:operator\nDo a manual thing.\n```\n');
|
||||
const res = await applySkill(oskill, oroot, { prompter: headless({}), exec: () => {} });
|
||||
expect(res.agentTasks).toEqual([]); // operator with no sink is fine, not bounced
|
||||
});
|
||||
});
|
||||
|
||||
+16
-1
@@ -4,7 +4,8 @@
|
||||
// accelerator it delegates to. Anything the engine can't do bounces back to the
|
||||
// AGENT (which reads the same prose and applies it, the way skills work today) —
|
||||
// never to the human, and never as a hard abort. The human is in the loop only
|
||||
// for `prompt` inputs and inherently-human prose (e.g. clicking through Slack).
|
||||
// for `prompt` inputs and `operator` instructions — the parts addressed to the
|
||||
// human (e.g. clicking through the Slack UI), which the agent relays.
|
||||
//
|
||||
// Phases (the F2 runtime contract, minimal form):
|
||||
// 1. parse + validate — lint; a malformed skill never reaches apply
|
||||
@@ -28,6 +29,10 @@ import { parseDirectives, promptVar, type Directive } from './skill-directives.j
|
||||
export interface Prompter {
|
||||
// Return the value, or undefined to DEFER (headless rebuild collects these).
|
||||
ask(varName: string, question: string, secret: boolean): Promise<string | undefined>;
|
||||
// Show an `nc:operator` block to the human operator (a clack note in setup, a
|
||||
// channel message when a coding agent relays). Absent ⇒ no operator present
|
||||
// (headless rebuild), so the instructions are simply skipped.
|
||||
tell?(text: string): Promise<void> | void;
|
||||
}
|
||||
|
||||
export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent';
|
||||
@@ -127,6 +132,8 @@ function selfStatus(d: Directive, root: string): { status: StepStatus; detail: s
|
||||
}
|
||||
case 'prompt':
|
||||
return { status: 'needs-input', detail: '' };
|
||||
case 'operator':
|
||||
return { status: 'apply', detail: `show operator: ${(d.body[0] ?? '').slice(0, 50)}…` };
|
||||
default:
|
||||
return { status: 'agent', detail: `no deterministic handler for nc:${d.kind} — an agent applies it from the prose` };
|
||||
}
|
||||
@@ -372,6 +379,14 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti
|
||||
else vars.set(v, { value: val, secret: d.args.includes('secret') });
|
||||
continue;
|
||||
}
|
||||
if (d.kind === 'operator') {
|
||||
// Relay the human-facing instructions. No operator present (headless
|
||||
// rebuild) ⇒ nothing to show. {{vars}} render so a resolved value can be
|
||||
// shown to the operator (throws → deferred if a referenced var is unset).
|
||||
await opts.prompter.tell?.(substitute(d.body.join('\n'), vars));
|
||||
res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`);
|
||||
continue;
|
||||
}
|
||||
const st = selfStatus(d, root);
|
||||
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
|
||||
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
|
||||
|
||||
@@ -15,10 +15,12 @@ describe('skill-directives parser, on the converted add-slack', () => {
|
||||
'dep', // step 3: pinned package
|
||||
'run', // step 4: build
|
||||
'run', // step 4: test
|
||||
'operator', // credentials: create-app walkthrough (addressed to the operator)
|
||||
'prompt', // credentials: capture bot token
|
||||
'prompt', // credentials: capture signing secret
|
||||
'env-set', // credentials: write captured values to .env
|
||||
'env-sync', // credentials: sync to container
|
||||
'operator', // credentials: event-delivery walkthrough
|
||||
'prompt', // wire: owner member id
|
||||
'prompt', // wire: target agent folder
|
||||
'run', // wire: validate token (auth.test)
|
||||
@@ -27,6 +29,14 @@ describe('skill-directives parser, on the converted add-slack', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('delineates the human UI steps as nc:operator (not agent prose or a run)', () => {
|
||||
const ops = directives.filter((d) => d.kind === 'operator');
|
||||
expect(ops).toHaveLength(2);
|
||||
expect(ops[0].body.join('\n')).toMatch(/Create the Slack app/);
|
||||
expect(ops[0].body.join('\n')).toMatch(/Bot Token Scopes/);
|
||||
expect(ops[1].body.join('\n')).toMatch(/Event Subscriptions/);
|
||||
});
|
||||
|
||||
it('reads copy as a branch fetch with both files', () => {
|
||||
const copy = directives.find((d) => d.kind === 'copy')!;
|
||||
expect(copy.attrs['from-branch']).toBe('channels');
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
// command's stdout into {{var}} (twin of prompt) — e.g. resolve an id
|
||||
// from an API and feed it to a later directive.
|
||||
// prompt <var> [secret] body: the question → binds {{var}} skip if satisfied
|
||||
// operator body: instructions for the human operator output-only
|
||||
// The SKILL.md is addressed to the coding agent; `operator` delineates the
|
||||
// parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it
|
||||
// with agent-facing prose like "Tell the user:" so the agent relays it;
|
||||
// the engine renders the body to the operator ({{vars}} substituted in).
|
||||
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
|
||||
// env-sync (no body) `.env` → data/env/env idempotent copy
|
||||
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
|
||||
@@ -58,7 +63,7 @@ export interface Problem {
|
||||
const FENCE = /^```(\S.*)?$/;
|
||||
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'env-set', 'env-sync', 'json-merge']);
|
||||
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'env-sync', 'json-merge']);
|
||||
const PROMPT_FLAGS = new Set(['secret']);
|
||||
|
||||
export function parseDirectives(markdown: string): Directive[] {
|
||||
@@ -183,6 +188,9 @@ export function validate(directives: Directive[], ctx?: { chatVersion?: string }
|
||||
if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`');
|
||||
if (d.body.length === 0) flag(d, 'prompt requires a question in its body');
|
||||
break;
|
||||
case 'operator':
|
||||
if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body');
|
||||
break;
|
||||
}
|
||||
// A consumer can only reference a variable an earlier prompt captured, or an
|
||||
// earlier `run capture:<var>` bound from a command's output.
|
||||
|
||||
Reference in New Issue
Block a user