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
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,25 @@
transfers</a>.</em>
</p>

## The refund that happened twice

An agent refunds €500. The call reaches the provider and commits. The reply is lost on the way
back, so the agent sees an error — and does what every retrying client does. The customer is
refunded twice, and nothing in the stack noticed.

The bug is not the retry. It is that the agent had no way to tell *this failed* from *I do not
know what happened*. Retry libraries, agent frameworks and tool loops collapse those two into
one, and a write that may already have committed is retried as though it certainly had not.

CTRLRun does not collapse them. A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against
an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened.
## The refund nobody approved

A ticket asks for a €500 refund. The agent calls the refund tool with €5,000 — one extra zero.
The tool is in its list, the arguments are well-formed, and the model is completely confident.
Nothing above the call disagrees, because nothing above the call is a check: a tool being
callable is not permission to call it with those arguments.

CTRLRun is that check. It reads the arguments about to leave your process and answers what may
happen to them. Under the demo's policy €500 is autonomous and €5,000 is not, so the extra zero
meets a human instead of the provider. Nor does an approval travel: the demo's second scenario
approves €2,000, executes €5,000 under that approval, and it authorises nothing, because it was
bound to the action the human actually read.

That is the half people expect. The other half is the same agent making a *correct* €500 refund
that commits at the provider while the reply is lost coming back. The agent sees an error and
retries, because retry libraries, agent frameworks and tool loops collapse *this failed* into *I
do not know what happened*. CTRLRun keeps them apart: a lost reply is `AMBIGUOUS`, never
`FAILED`, and a retry against an `AMBIGUOUS` effect is refused until a human, or a `reconcile`
hook, says what happened.

```bash
pip install ctrlrun && ctrlrun demo
Expand Down
6 changes: 3 additions & 3 deletions docs/docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ title: "The execution safety layer for AI agents"
sidebarTitle: "Overview"
description: "The last check before an AI agent does something it can't undo. Autonomy belongs to the action, not the agent."
mode: "wide"
"og:title": "CTRLRun the execution safety layer for AI agents"
"twitter:title": "CTRLRun the execution safety layer for AI agents"
"og:title": "CTRLRun: the execution safety layer for AI agents"
"twitter:title": "CTRLRun: the execution safety layer for AI agents"
canonical: "https://ctrlrun.dev/docs"
---

CTRLRun is a Python library that sits between an agent's decision to act and the call that acts.
A consequential action happens at most once, exactly as approved, and leaves a receipt and when
A consequential action happens at most once, exactly as approved, and leaves a receipt, and when
the outcome is unknown, CTRLRun says so instead of guessing.

```bash
Expand Down
115 changes: 75 additions & 40 deletions docs/index.mdx

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/snippets/architecture-review.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ export const ArchitectureReview = () => {
if (['High', 'Needs review', 'Lower indicated risk', 'Planning'].includes(level)) {
const count = Math.max(0, Math.min(5, Number(params.get('patterns')) || 0));
const unknowns = Math.max(0, Math.min(5, Number(params.get('unknowns')) || 0));
setRisk(level + ' ' + count + ' patterns, ' + unknowns + ' unknowns (self-reported)');
setRisk(level + ': ' + count + ' patterns, ' + unknowns + ' unknowns (self-reported)');
}
}, []);
useEffect(() => { if (prepared && reviewRef.current) reviewRef.current.focus(); }, [prepared]);
const brief = ['Architecture review request', '', 'Company: ' + company, 'Reply email: ' + emailAddress, domain && 'Domain: ' + domain, 'Agent purpose: ' + purpose, 'Actions it can execute: ' + actions, 'Production status: ' + status, 'Primary concerns: ' + (concerns.join(', ') || 'Discuss during review'), risk && 'Execution risk check: ' + risk].filter(line => line !== false).join('\n');
const email = 'mailto:contact@arpanghoshal.com?subject=' + encodeURIComponent('CTRLRun architecture review ' + company) + '&body=' + encodeURIComponent(brief);
const email = 'mailto:contact@arpanghoshal.com?subject=' + encodeURIComponent('CTRLRun architecture review: ' + company) + '&body=' + encodeURIComponent(brief);
const sendReview = async () => {
if (sendingRef.current || sent) return;
sendingRef.current = true; setSending(true); setError('');
Expand Down
121 changes: 121 additions & 0 deletions docs/snippets/deck-nav.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/* Controls for the homepage deck. The slides themselves are plain markup in
index.mdx, so the page still reads and indexes with no JavaScript: without
this component it is a scroll-snap deck, and with it the arrows, the dots
and the keyboard work. */
export const DeckNav = ({ labels = [] }) => {
const [active, setActive] = useState(0);
const [count, setCount] = useState(labels.length);
const deckRef = useRef(null);
const slidesRef = useRef([]);
const activeRef = useRef(0);

const goTo = (index) => {
const deck = deckRef.current;
const slides = slidesRef.current;
if (!deck || !slides.length) return;
const target = slides[Math.max(0, Math.min(slides.length - 1, index))];
if (!target) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
deck.scrollTo({ top: target.offsetTop, behavior: reduce ? 'instant' : 'smooth' });
};

useEffect(() => {
const deck = document.querySelector('.cr-deck');
if (!deck) return undefined;
deckRef.current = deck;
slidesRef.current = Array.from(deck.querySelectorAll('.cr-slide'));
setCount(slidesRef.current.length);

// The deck sits under whatever chrome the docs theme renders above it, and
// every slide is exactly one viewport. Where a composition is taller than
// the room it has, scale it down rather than let it scroll or clip.
const fit = () => {
const navbar = document.getElementById('navbar');
const top = navbar ? navbar.getBoundingClientRect().height : 0;
deck.style.setProperty('--cr-chrome', Math.round(top) + 'px');
// One scale for the whole deck, not one per slide: a slide scaled on its
// own would sit at a different left edge from its neighbours, and the
// misalignment reads as a mistake when you arrow between them.
let scale = 1;
slidesRef.current.forEach((slide) => {
const inner = slide.querySelector('.cr-slide-inner');
if (!inner) return;
const style = getComputedStyle(slide);
const roomY = slide.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
const roomX = slide.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
// offsetHeight is layout height, so it does not compound with the scale
// already applied on the previous pass.
scale = Math.min(scale, roomY / Math.max(1, inner.offsetHeight), roomX / Math.max(1, inner.offsetWidth));
});
deck.style.setProperty('--cr-slide-scale', String(Math.max(0.5, scale)));
};

let frame = 0;
const update = () => {
const slides = slidesRef.current;
if (!slides.length) return;
let current = 0;
slides.forEach((slide, i) => {
const near = Math.abs(slide.offsetTop - deck.scrollTop);
if (near < Math.abs(slides[current].offsetTop - deck.scrollTop)) current = i;
});
activeRef.current = current;
setActive(current);
};
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => { fit(); update(); });
};

fit();
update();
deck.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
cancelAnimationFrame(frame);
deck.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
}, []);

useEffect(() => {
const onKey = (event) => {
// Leave typing, search and control activation alone.
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.target instanceof Element && event.target.closest('input, textarea, select, [contenteditable]')) return;
if (event.key === ' ' && event.target instanceof Element && event.target.closest('button, a')) return;
const steps = { ArrowDown: 1, PageDown: 1, ' ': 1, ArrowUp: -1, PageUp: -1 };
if (event.key in steps) {
event.preventDefault();
if (!event.repeat) goTo(activeRef.current + steps[event.key]);
} else if (event.key === 'Home') {
event.preventDefault();
goTo(0);
} else if (event.key === 'End') {
event.preventDefault();
goTo(slidesRef.current.length - 1);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);

const names = count === labels.length ? labels : Array.from({ length: count }, (_, i) => 'Slide ' + (i + 1));

// Dots only. They are the deck's whole visible chrome, so unlike a decorative
// indicator they carry real labels and take focus.
return (
<nav className="cr-deck-dots" aria-label="Slide navigation">
{names.map((label, i) => (
<button
key={label}
type="button"
className="cr-dot-btn"
aria-current={i === active ? 'true' : 'false'}
aria-label={'Slide ' + (i + 1) + ' of ' + count + ': ' + label}
onClick={() => goTo(i)}
/>
))}
</nav>
);
};
6 changes: 3 additions & 3 deletions docs/snippets/scenario-explorer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const ScenarioExplorer = ({ standalone = false }) => {
tone: 'green',
reason: 'This action is inside the autonomy you gave the agent, so no person is asked.',
llm: 'This is within my limits, so I will call the tool.',
kernel: 'Agreed and the effect is reserved first, so a second worker proposing the same thing now waits.',
kernel: 'Agreed, and the effect is reserved first, so a second worker proposing the same thing now waits.',
rule: money ? 'Example rule: this agent may act autonomously up to $1,000.' : 'Example rule: this agent may perform this action on the selected target.',
sees: 'decision=allow → your function runs',
doc: { href: '/docs/concepts/decisions', label: 'Decisions' },
Expand Down Expand Up @@ -186,7 +186,7 @@ export const ScenarioExplorer = ({ standalone = false }) => {
reason: 'The real system confirmed the action. The outcome and the decision behind it are written to a receipt.',
llm: 'Done. I will report success.',
kernel: 'Committed. Any later attempt at the same business action now has something to be refused against.',
rule: 'A receipt records the action, the decision and the outcome including who approved it.',
rule: 'A receipt records the action, the decision and the outcome, including who approved it.',
sees: 'outcome=committed · receipt rcp_4c2a…',
doc: { href: '/docs/concepts/receipts-and-evidence', label: 'Receipts and evidence' },
marks: ['pass', 'pass', viaApproval ? 'pass' : 'skip', 'pass', 'pass'],
Expand Down Expand Up @@ -308,7 +308,7 @@ export const ScenarioExplorer = ({ standalone = false }) => {
{!standalone && <a className="cr-text-link" href={shareUrl()}>Open on its own page ↗</a>}
</div>
</div>
<p className="cr-demo-note">Nothing here executes: this is an illustration of the decisions, with example rules that are not industry defaults. To watch the real library refuse a real call, <a href="/docs/try-it">run the released wheel in your browser</a> Python and CTRLRun load into the tab and every refusal there is the library's own.{physical ? ' CTRLRun governs the software authorization and execution workflow; physical safety controls remain separate.' : ''}{domain === 'Healthcare Operations' ? ' Administrative workflows only; no diagnosis or clinical decisions.' : ''}</p>
<p className="cr-demo-note">Nothing here executes: this is an illustration of the decisions, with example rules that are not industry defaults. To watch the real library refuse a real call, <a href="/docs/try-it">run the released wheel in your browser</a>: Python and CTRLRun load into the tab and every refusal there is the library's own.{physical ? ' CTRLRun governs the software authorization and execution workflow; physical safety controls remain separate.' : ''}{domain === 'Healthcare Operations' ? ' Administrative workflows only; no diagnosis or clinical decisions.' : ''}</p>
<div className="cr-domain-cta"><div><strong>{reviewQuestion}</strong><p>Review where execution controls belong in your architecture.</p></div><a className="cr-text-link" href={'/protect-my-agent?domain=' + encodeURIComponent(domain)} onClick={() => track('protect_clicked')}>Get a safety review ↗</a></div>
</div>
);
Expand Down
Loading