כשהסוכן רץ ישר לפעולהWhen the agent dives straight into action
סוכנים שמקבלים tools חזקים נוטים להשתמש בהם. זה אינטואיטיבי — אם נתת ל-LLM פטיש, הוא יראה מסמרים. הבעיה היא שחלק מה-tools מייצרים פעולות בלתי הפיכות: delete_project, send_email, charge_card, deploy_to_production. שגיאה אחת בקריאה כזו זה לא רק bug — זו מחיקה של עבודה של לקוח.
הפתרון התחיל אצלנו ב-2026 כשמשתמש כתב "בוא ננקה את הפרויקטים הישנים". הסוכן הבין כ-"delete_project על כל פרויקט שלא נגעו בו 30 יום". הוא ביצע. שניים מהפרויקטים שנמחקו היו פעילים — פשוט לא נגעו בהם בעורך, אבל היו בייצור.
למידה: הסוכן צריך להציע תוכנית לפני שהוא מבצע אותה. ולא רק תוכנית — הערכה כמותית של עד כמה היא מסוכנת. ההערכה הזאת היא הציר שמחבר את הסוכן למערכת ההחלטות החיצונית.
Agents handed powerful tools tend to use them. It is intuitive — give an LLM a hammer and it sees nails. The problem is that some tools create irreversible side effects: delete_project, send_email, charge_card, deploy_to_production. A single misread on one of those calls is not a bug — it is a customer's work, gone.
For us this started in early 2026 when a user wrote "let's clean up the old projects". The agent interpreted that as "delete_project on anything untouched for thirty days". It executed. Two of the deleted projects were active — they had not been edited recently, but they were live in production.
The lesson: the agent must propose a plan before it executes it. And not just a plan — a quantitative assessment of how dangerous the plan is. That score is the bridge between the agent and the external decision system.
ארבעת צירי הסיכוןThe four risk axes
הציון הוא לא תחושה. הוא חישוב פשוט על ארבעה צירים שאנחנו מצהירים עליהם בפירוש ב-system prompt:
- Destructiveness: כמה הפעולה הורסת מידע או מצב. קריאה - 1. כתיבה ל-resource חדש - 2. עדכון resource קיים - 3. מחיקה - 5.
- Blast radius: כמה ישויות נפגעות. אחת - 1. עשרות - 3. כל ה-tenant - 5.
- Reversibility: כמה קל לחזור אחורה. trivial undo - 1. צריך restore מ-backup - 3. בלתי הפיך - 5.
- Cost: משאבים שנצרכים. שניות של CPU - 1. שעות של GPU - 3. אלפי דולרים של API - 5.
ה-risk_score הסופי הוא המקסימום בין הצירים, לא הממוצע. סיבה: פעולה הרסנית עם blast קטן עדיין מסוכנת. הממוצע היה ממסך אותה.
function riskScore({ destructiveness, blast, reversibility, cost }) {
return Math.max(destructiveness, blast, reversibility, cost);
}הסוכן מקבל את הטבלה הזאת בפרומפט המערכת ומחויב להחזיר את הציר הספציפי שהביא לציון - לא רק את המספר הסופי. זה מאפשר ל-gate החיצוני לבדוק שההערכה מבוססת.
The score is not a vibe. It is a small calculation over four axes that we declare explicitly in the system prompt:
- Destructiveness: how much state the action destroys. Read - 1. Create new resource - 2. Update existing - 3. Delete - 5.
- Blast radius: how many entities are affected. One - 1. Dozens - 3. Whole tenant - 5.
- Reversibility: how easy it is to undo. Trivial - 1. Backup restore - 3. Irreversible - 5.
- Cost: resources consumed. CPU seconds - 1. GPU hours - 3. Thousands in API spend - 5.
The final risk_score is the max across axes, not the mean. Reason: a destructive action with a small blast is still dangerous, and averaging would mask it.
function riskScore({ destructiveness, blast, reversibility, cost }) {
return Math.max(destructiveness, blast, reversibility, cost);
}The agent is given this rubric in the system prompt and required to return the specific axis that drove the score, not just the final number. This lets the external gate sanity-check that the rating is grounded.
סכמה של תוכניתPlan schema
התוכנית היא tool בפני עצמו. הסוכן קורא ל-propose_plan, מקבל בחזרה אישור או דחיה, ורק אם אישור — קורא ל-tools המבצעים. ה-input_schema הוא JSON Schema רגיל.
{
"name": "propose_plan",
"description": "Propose a plan and risk assessment before any mutating action. Required for any tool that writes, deletes, deploys, charges, or sends.",
"input_schema": {
"type": "object",
"required": ["intent", "steps", "risk"],
"properties": {
"intent": { "type": "string", "description": "One sentence describing what this plan accomplishes" },
"steps": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["tool", "args_summary"],
"properties": {
"tool": { "type": "string" },
"args_summary": { "type": "string" }
}
}
},
"risk": {
"type": "object",
"required": ["score", "driver", "reason"],
"properties": {
"score": { "type": "integer", "minimum": 1, "maximum": 5 },
"driver": { "type": "string", "enum": ["destructiveness", "blast", "reversibility", "cost"] },
"reason": { "type": "string", "maxLength": 200 }
}
}
}
}
}args_summary ולא args מלאים: בשלב התכנון אנחנו לא רוצים אובייקטים גדולים, רק תיאור קצר של מה ירוץ. זה מאלץ את הסוכן לחשוב על הפעולה ברמה גבוהה.
The plan is itself a tool. The agent calls propose_plan, gets back an approval or rejection, and only on approval does it invoke the actual mutating tools. The input_schema is plain JSON Schema.
{
"name": "propose_plan",
"description": "Propose a plan and risk assessment before any mutating action. Required for any tool that writes, deletes, deploys, charges, or sends.",
"input_schema": {
"type": "object",
"required": ["intent", "steps", "risk"],
"properties": {
"intent": { "type": "string", "description": "One sentence describing what this plan accomplishes" },
"steps": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["tool", "args_summary"],
"properties": {
"tool": { "type": "string" },
"args_summary": { "type": "string" }
}
}
},
"risk": {
"type": "object",
"required": ["score", "driver", "reason"],
"properties": {
"score": { "type": "integer", "minimum": 1, "maximum": 5 },
"driver": { "type": "string", "enum": ["destructiveness", "blast", "reversibility", "cost"] },
"reason": { "type": "string", "maxLength": 200 }
}
}
}
}
}args_summary rather than full args: at planning time we do not want enormous payloads, just a short description of what would run. The constraint forces the agent to reason about the action at the right level.
ה-Gate: כיצד מנתבים את ההחלטהThe gate: routing the decision
ה-handler של propose_plan פשוט. הוא לא מריץ כלום. הוא רק בודק את ה-score ומחליט מה הצעד הבא.
async function handleProposePlan(plan, ctx) {
await persistPlan(plan, ctx); // לוג מלא לכל תוכנית, גם מאושרות אוטומטית
if (plan.risk.score >= 4) {
const approval = await requestHumanApproval({
planId: plan.id,
summary: plan.intent,
steps: plan.steps,
risk: plan.risk,
timeoutMs: 1000 * 60 * 10,
});
if (!approval.granted) {
return { approved: false, reason: approval.reason || 'rejected' };
}
return { approved: true, approver: approval.approver };
}
// risk <= 3: auto-approve, but log
return { approved: true, approver: 'auto' };
}גם תוכניות שעוברות אוטומטית נשמרות. זה מאפשר ביקורת בדיעבד וגם מאפשר לשנות את הסף בלי לאבד היסטוריה. אם בעוד שבוע נחליט שגם risk=3 צריך אישור ל-deploy לפרודקשן, יש לנו את הנתונים לקבוע איפה הסף הנכון.
The handler for propose_plan is simple. It runs nothing. It just inspects the score and decides the next step.
async function handleProposePlan(plan, ctx) {
await persistPlan(plan, ctx); // log every plan, including auto-approved ones
if (plan.risk.score >= 4) {
const approval = await requestHumanApproval({
planId: plan.id,
summary: plan.intent,
steps: plan.steps,
risk: plan.risk,
timeoutMs: 1000 * 60 * 10,
});
if (!approval.granted) {
return { approved: false, reason: approval.reason || 'rejected' };
}
return { approved: true, approver: approval.approver };
}
// risk <= 3: auto-approve, but log
return { approved: true, approver: 'auto' };
}Even auto-approved plans are persisted. That gives you retroactive audit, and lets you tune the threshold without losing history. If next week we decide that risk=3 deploys to production also need approval, the data is there to pick a defensible cutoff.
חיווט הכלים: איך אוכפים שתוכנית הייתהWiring the tools: enforcing the plan came first
תוכנית בלי אכיפה היא בקשה מנומסת. אנחנו צריכים לוודא שאף mutating tool לא ירוץ בלי plan_id מאושר. ההגנה היא בשתי שכבות: ב-system prompt, ובקוד.
ב-system prompt:
Before calling any tool tagged mutating, you must call
propose_planfirst and wait forapproved: true. Pass the returnedplan_idas the first argument to the mutating tool.
בקוד:
const MUTATING_TOOLS = new Set([
'delete_project', 'deploy', 'charge_card',
'send_email', 'write_file', 'execute_sql_write',
]);
async function executeTool(toolName, args, ctx) {
if (MUTATING_TOOLS.has(toolName)) {
const planId = args.plan_id;
if (!planId) {
return toolError('missing_plan_id', `${toolName} requires a plan_id from propose_plan`);
}
const plan = await loadApprovedPlan(planId, ctx);
if (!plan || !plan.approved) {
return toolError('plan_not_approved', 'plan_id is missing or not approved');
}
if (!plan.steps.some(s => s.tool === toolName)) {
return toolError('plan_mismatch', `${toolName} not present in approved plan`);
}
}
return runTool(toolName, args, ctx);
}הבדיקה האחרונה — שה-tool שנקרא באמת מופיע בתוכנית — חיונית. בלעדיה הסוכן יכול להגיש תוכנית של "write_file אחד תמים", לקבל אישור, ואז לקרוא ל-delete_project עם אותו plan_id.
A plan without enforcement is a polite request. We have to make sure no mutating tool runs without an approved plan_id. The defense is two-layered: in the system prompt and in code.
In the system prompt:
Before calling any tool tagged mutating, you must call
propose_planfirst and wait forapproved: true. Pass the returnedplan_idas the first argument to the mutating tool.
In code:
const MUTATING_TOOLS = new Set([
'delete_project', 'deploy', 'charge_card',
'send_email', 'write_file', 'execute_sql_write',
]);
async function executeTool(toolName, args, ctx) {
if (MUTATING_TOOLS.has(toolName)) {
const planId = args.plan_id;
if (!planId) {
return toolError('missing_plan_id', `${toolName} requires a plan_id from propose_plan`);
}
const plan = await loadApprovedPlan(planId, ctx);
if (!plan || !plan.approved) {
return toolError('plan_not_approved', 'plan_id is missing or not approved');
}
if (!plan.steps.some(s => s.tool === toolName)) {
return toolError('plan_mismatch', `${toolName} not present in approved plan`);
}
}
return runTool(toolName, args, ctx);
}The last check — that the called tool actually appears in the plan — matters. Without it, the agent can submit a benign "write_file" plan, get approval, then call delete_project with the same plan_id.
כיול: כשהסוכן מציין נמוך מדיCalibration: when the agent under-rates itself
הסוכן יטעה. בהתחלה ראינו דפוס ברור: הסוכן מעריך את עצמו בעקביות נמוך. "delete_project על 12 פרויקטים" קיבל risk=3 כי הוא ראה זה כפעולה רגילה.
שתי תרופות:
- Static rules. חלק מה-tools מקבלים floor מינימלי קשיח.
delete_*אף פעם לא מתחת ל-4.charge_cardאף פעם לא מתחת ל-4. הסף נקבע בקוד, לא ב-LLM. זה תופס שגיאות הערכה לפני שהן מגיעות ל-gate. - Periodic recalibration. פעם ברבעון אנחנו לוקחים sample של 200 תוכניות אוטומטיות, אדם מסווג אותן ידנית, ומשווים לציון של ה-LLM. אם הוא מתחת ביותר מ-15% מהזמן, אנחנו מחדדים את ה-rubric בפרומפט המערכת.
const RISK_FLOOR = {
delete_project: 4,
delete_user: 5,
charge_card: 4,
send_email: 3,
deploy_to_production: 4,
};
function effectiveRisk(plan) {
const declared = plan.risk.score;
const floor = Math.max(...plan.steps.map(s => RISK_FLOOR[s.tool] || 0));
return Math.max(declared, floor);
}The agent will be wrong. Early on we saw a consistent pattern: the agent under-rated. "delete_project on 12 projects" came in at risk=3 because the model treated it as routine.
Two remedies:
- Static rules. Some tools carry a hard floor.
delete_*never below 4.charge_cardnever below 4. The floor is in code, not in the LLM. It catches under-rating before the score reaches the gate. - Periodic recalibration. Once a quarter we sample 200 auto-approved plans, have a human label them, and compare to the LLM's score. If it under-rates more than 15% of the time, we sharpen the rubric in the system prompt.
const RISK_FLOOR = {
delete_project: 4,
delete_user: 5,
charge_card: 4,
send_email: 3,
deploy_to_production: 4,
};
function effectiveRisk(plan) {
const declared = plan.risk.score;
const floor = Math.max(...plan.steps.map(s => RISK_FLOOR[s.tool] || 0));
return Math.max(declared, floor);
}