Skip to content
Draft
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
8 changes: 8 additions & 0 deletions docs/16_codemode_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,12 @@ the container and worker examples, plus a `/c/<name>/agent` route.
`script/run` is a smoke test that round-trips one file through every
backend.

That agent layer asks a human before it runs anything that writes, and
before anything at all on the `container` backend. A held-back command
does not execute: the turn pauses and resumes through
`/c/<name>/approvals` once someone answers. The approval policy and the
paused-turn state both live in the example, not in this backend — the
backend runs a command and reports the result, as it did before. See
the example's README for the flow.

Run with `npm run dev --workspace @example/workspace-codemode`.
480 changes: 462 additions & 18 deletions examples/codemode/README.md

Large diffs are not rendered by default.

517 changes: 515 additions & 2 deletions examples/codemode/architecture.html

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions examples/codemode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand All @@ -17,7 +18,9 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260616.1",
"just-bash": "^3.0.1",
"typescript": "^6.0.3",
"vitest": "^4.1.7",
"wrangler": "^4.96.0"
}
}
167 changes: 167 additions & 0 deletions examples/codemode/script/agent
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env node
// Drive one agent turn against a running `wrangler dev`, stopping to ask
// on the terminal for every command the approval policy holds back.
//
// The HTTP surface answers approvals with a second request, which is
// the right shape for a UI but an awkward way to see the feature work.
// This script closes that loop: it starts a turn, and each time the
// turn comes back waiting on a human it prints the command, asks y/n,
// posts the answer, and picks the turn up where it left off. What you
// see is what a real approval UI would drive.
//
// ./script/agent "Create /workspace/notes.txt saying hello"
// NAME=foo ./script/agent "..." # workspace instance
// BASE_URL=http://127.0.0.1:8799 ./script/agent "..."
// ./script/agent # uses a default prompt
// AUTO_APPROVE=1 ./script/agent "..." # say yes to everything
// printf 'y\nn\n' | ./script/agent "..." # scripted answers

const BASE_URL = (process.env.BASE_URL ?? "http://127.0.0.1:8787").replace(/\/$/, "");
// A fresh instance by default, so a demo starts from an empty tree and
// "the file is not there yet" means what it looks like.
const NAME = process.env.NAME ?? `agent-${Date.now().toString(36)}`;
const PROMPT =
process.argv.slice(2).join(" ") ||
"Create /workspace/greeting.txt containing exactly: hello world";

const base = `${BASE_URL}/c/${NAME}`;

const bold = (s) => `\u001b[1m${s}\u001b[0m`;
const dim = (s) => `\u001b[2m${s}\u001b[0m`;
const green = (s) => `\u001b[32m${s}\u001b[0m`;
const red = (s) => `\u001b[31m${s}\u001b[0m`;
const yellow = (s) => `\u001b[33m${s}\u001b[0m`;

async function post(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const text = await response.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 200)}`);
}
if (parsed.error != null) {
throw new Error(`${url} returned HTTP ${response.status}: ${parsed.error}`);
}
return parsed;
}

function reportRan(toolCalls, alreadyReported) {
for (const call of toolCalls.slice(alreadyReported)) {
const status = call.exitCode === 0 ? green("ran") : red(`exit ${call.exitCode}`);
console.log(` ${status} ${dim(`[${call.backend}]`)} ${call.command.replace(/\n/g, " ")}`);
const out = (call.stdout || call.stderr).trim();
if (out.length > 0) console.log(dim(` ${out.slice(0, 200).replace(/\n/g, "\n ")}`));
}
return toolCalls.length;
}

/**
* Read answers a line at a time from stdin.
*
* Not `node:readline`, because a piped answer arrives and ends the
* stream while the first model call is still in flight, and readline
* then rejects the question that follows with "readline was closed".
* Buffering from the start works for a terminal and a pipe alike.
* Returns null once stdin has no more to give.
*/
function lineReader() {
const ready = [];
const waiting = [];
let buffer = "";
let ended = false;

process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
let index = buffer.indexOf("\n");
while (index >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
const waiter = waiting.shift();
if (waiter) waiter(line);
else ready.push(line);
index = buffer.indexOf("\n");
}
});
process.stdin.on("end", () => {
ended = true;
if (buffer.length > 0) {
const line = buffer;
buffer = "";
const waiter = waiting.shift();
if (waiter) waiter(line);
else ready.push(line);
}
while (waiting.length > 0) waiting.shift()(null);
});

return {
next() {
if (ready.length > 0) return Promise.resolve(ready.shift());
if (ended) return Promise.resolve(null);
return new Promise((resolve) => waiting.push(resolve));
},
};
}

const reader = lineReader();
const autoApprove = process.env.AUTO_APPROVE === "1";

/** Ask the human. No answer left on stdin means no. */
async function askApproval() {
if (autoApprove) {
console.log(` approve? ${dim("[y/N]")} y ${dim("(AUTO_APPROVE)")}`);
return true;
}
process.stdout.write(` approve? ${dim("[y/N]")} `);
const answer = await reader.next();
if (answer === null) {
console.log(dim("(no answer on stdin, treating as no)"));
return false;
}
if (!process.stdin.isTTY) console.log(answer.trim());
return /^y(es)?$/i.test(answer.trim());
}

try {
console.log(`${bold("workspace")} ${NAME} ${dim(base)}`);
console.log(`${bold("prompt")} ${PROMPT}\n`);

let turn = await post(`${base}/agent`, { prompt: PROMPT });
let reported = reportRan(turn.toolCalls, 0);

while (turn.status === "awaiting-approval") {
for (const approval of turn.pendingApprovals) {
console.log(`\n${yellow("APPROVAL NEEDED")} ${dim(`(${approval.backend})`)}`);
console.log(` ${bold(approval.command.replace(/\n/g, "\n "))}`);
console.log(` ${dim(`why: ${approval.reason}`)}`);

const approved = await askApproval();

// Nothing has run at this point. Approving is what executes it.
const outcome = await post(`${base}/approvals/${approval.approvalId}`, {
approved,
...(approved ? {} : { reason: "denied at the prompt" }),
});
console.log(approved ? green(" approved") : red(" denied"));
turn = outcome;
reported = reportRan(turn.toolCalls, reported);
}
}

console.log(`\n${bold("status")} ${turn.status}`);
console.log(`${bold("steps")} ${turn.stepsUsed}`);
if (turn.text) console.log(`${bold("agent")} ${turn.text}`);
console.log(dim(`\nturn record: curl -s ${base}/agent/${turn.turnId}`));
} catch (error) {
console.error(red(`\nFAIL: ${error.message}`));
process.exitCode = 1;
} finally {
process.stdin.pause();
}
61 changes: 59 additions & 2 deletions examples/codemode/script/run
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@
# surface, closing the loop.
#
# Steps 1 through 4 need no model and no container, so they run by
# default. Two further backends are opt-in because they cost more to
# stand up:
# default. The rest are opt-in because they cost more to stand up:
#
# CONTAINERS=1 also read the tree from inside the Cloudflare
# Container (boots wsd on first use; requires
# `wrangler dev --enable-containers`).
# AGENT=1 also run one agent turn, which needs the AI
# binding and a model round-trip.
# APPROVALS=1 also drive the human-in-the-loop approval flow: ask
# the agent for a write, check that nothing ran while
# it waits, approve, and check that it then did.
#
# Run `npm run dev` in another terminal first, then:
# ./script/run # against http://127.0.0.1:8787
Expand Down Expand Up @@ -91,4 +93,59 @@ if [[ "${AGENT:-}" == "1" ]]; then
printf '%s\n' "$agent_out"
fi

if [[ "${APPROVALS:-}" == "1" ]]; then
# A fresh instance, so "the file is not there yet" actually means the
# approval held the write back rather than that an earlier run had
# not written it.
HITL_NAME="${NAME}-hitl-$$"
HITL_BASE="${BASE_URL}/c/${HITL_NAME}"
HITL_PATH="approved-write.txt"

step "7. approval: ask for a write on /c/${HITL_NAME}/agent"
hitl_out=$(curl -fsS -X POST "${HITL_BASE}/agent" \
-H 'content-type: application/json' \
-d '{"prompt":"Create the file /workspace/'"${HITL_PATH}"' containing exactly: approved"}')
printf '%s\n' "$hitl_out"

hitl_status=$(JSON="$hitl_out" node -e 'process.stdout.write(JSON.parse(process.env.JSON).status)')
[[ "$hitl_status" == "awaiting-approval" ]] \
|| fail "expected the write to wait for approval, got status '$hitl_status'"

approval_id=$(JSON="$hitl_out" node -e \
'const d=JSON.parse(process.env.JSON); process.stdout.write(d.pendingApprovals[0].approvalId)')

step "8. approval: nothing ran while it waits"
code=$(curl -sS -o /dev/null -w '%{http_code}' "${HITL_BASE}/file/workspace/${HITL_PATH}")
[[ "$code" == "404" ]] \
|| fail "the command ran before it was approved (GET returned HTTP $code, expected 404)"
printf 'GET /file/workspace/%s -> HTTP 404, as it should be\n' "$HITL_PATH"

step "9. approval: the queue lists it"
queue=$(curl -fsS "${HITL_BASE}/approvals")
printf '%s\n' "$queue"
echo "$queue" | grep -q "$approval_id" \
|| fail "the pending approval is missing from GET /approvals"

step "10. approval: approve it, and the turn resumes"
resumed=$(curl -fsS -X POST "${HITL_BASE}/approvals/${approval_id}" \
-H 'content-type: application/json' -d '{"approved":true}')
printf '%s\n' "$resumed"
resumed_status=$(JSON="$resumed" node -e 'process.stdout.write(JSON.parse(process.env.JSON).status)')
[[ "$resumed_status" == "completed" ]] \
|| fail "expected the resumed turn to complete, got status '$resumed_status'"

step "11. approval: now the file exists"
written=$(curl -fsS "${HITL_BASE}/file/workspace/${HITL_PATH}")
printf '%s\n' "$written"
[[ -n "$written" ]] || fail "the approved command did not write the file"

step "12. approval: answering twice is refused"
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST "${HITL_BASE}/approvals/${approval_id}" \
-H 'content-type: application/json' -d '{"approved":true}')
[[ "$code" == "404" ]] \
|| fail "a second answer to the same approval was accepted (HTTP $code, expected 404)"
printf 'second answer -> HTTP 404, so the command cannot run twice\n'
fi

printf '\nOK — one filesystem, reached through every backend.\n'
Loading
Loading