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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,3 +594,16 @@ the source discards the pending result. Reloading does not replay inference. The
browser rechecks source evidence before and after the response; a removed or
changed source refuses the interpretation. Cancellation stops display and browser
transport, but cannot guarantee the provider stops work already received.

When the connected server advertises `agents.usage`, completed workflow results
show **Reported token usage** for each model task or handoff hop. Prompt,
completion, and total counts each include their reporting coverage; missing
reports display **Unknown**, and historical tasks without telemetry say that it
was not recorded. Saved results keep their original counts across restart and
repeated reads. Human replies have no model usage, and a final handoff is shown
once with its hop.

This view requires the native workflow usage contract and explicitly requests
`include_usage=true`. Older servers retain the existing result view. The counts
are provider reports for completed tasks, not billing totals or quality scores;
failed and interrupted attempts can consume tokens outside these receipts.
4 changes: 3 additions & 1 deletion scripts/fixtures/agent-input-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from scone_memory import HashEmbedder, InMemoryDocumentStore, InMemoryVectorIndex, MemoryEngine
from scone_memory.agents.catalog import AgentCatalog, AgentDefinition, AgentModel
from scone_memory.agents.evidence_loop import ToolStep
from scone_memory.agents.usage import ModelTokenUsage
from scone_memory.agents.plan_store import AgentPlanStore
from scone_memory.agents.run_service import AgentRunService
from scone_memory.api.app import create_app
Expand All @@ -24,7 +25,8 @@ def __init__(self, name):
async def complete(self, messages, tools):
with (state / 'calls.jsonl').open('a') as output:
output.write(json.dumps({'model': self.name, 'messages': messages}) + '\n')
return ToolStep(content=self.name + ' completed the task.')
usage = ModelTokenUsage(prompt_tokens=120, completion_tokens=18, total_tokens=138) if self.name == 'careful' else ModelTokenUsage(prompt_tokens=25)
return ToolStep(content=self.name + ' completed the task.', usage=usage)
catalog = AgentCatalog(models=[AgentModel(name, name.title() + ' local', '1', lambda name=name: Model(name))
for name in ('fast', 'careful')], agents=[AgentDefinition(
agent_id='worker', instructions='Use the provided direction.', models=('fast', 'careful'),
Expand Down
10 changes: 9 additions & 1 deletion scripts/test-agent-inputs-native.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ for(const mobile of [false,true])test(`human replies survive process restart wit
t.after(async()=>{await browser?.close();await stop();fs.rmSync(state,{recursive:true,force:true});});
await start();browser=await engines[process.env.SCONE_BROWSER_ENGINE||'chromium'].launch({headless:true,executablePath:process.env.SCONE_BROWSER_PATH});
const page=await browser.newPage({viewport:mobile?{width:390,height:844}:{width:1380,height:1000}});page.setDefaultTimeout(7000);
const errors=[];page.on('pageerror',error=>errors.push(error.message));
const errors=[],resultRequests=[];page.on('pageerror',error=>errors.push(error.message));page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/result'))resultRequests.push(request.url());});
await page.goto(base+'/agents');
await page.getByLabel('Workflow identifier',{exact:true}).fill('interactive');
await page.getByLabel('Task type',{exact:true}).selectOption('input');
Expand Down Expand Up @@ -58,6 +58,14 @@ for(const mobile of [false,true])test(`human replies survive process restart wit
const completed=calls();assert.deepEqual(completed.map(call=>call.model),['fast','careful']);assert.match(JSON.stringify(completed[1].messages),/Take the northern route/);
await view.getByRole('button',{name:'Check status',exact:true}).click();await page.getByText('careful completed the task.',{exact:true}).waitFor();assert.equal(calls().length,2);
assert.equal(await page.getByRole('heading',{name:'task-1 · Human input',exact:true}).count(),1);
const careful=page.getByRole('heading',{name:'task-2 · worker · careful',exact:true}).locator('..');
const fast=page.getByRole('heading',{name:'task-3 · worker · fast',exact:true}).locator('..');
assert.equal(await careful.getByRole('cell',{name:'138',exact:true}).count(),1);
assert.equal(await fast.getByRole('cell',{name:'Unknown',exact:true}).count(),2);
assert.equal(await page.getByRole('table',{name:'Reported token usage'}).count(),2);
assert(resultRequests.length>0&&resultRequests.every(url=>new URL(url).searchParams.get('include_usage')==='true'));
assert.equal(calls().length,2);
if(process.env.SCONE_USAGE_SCREENSHOTS)await page.screenshot({path:path.join(process.env.SCONE_USAGE_SCREENSHOTS,`usage-${mobile?'mobile':'desktop'}.png`),fullPage:true});
await page.getByRole('button',{name:/interactive.*Revision 1/}).first().click();
await page.getByRole('button',{name:'Prepare a new run',exact:true}).count().then(async count=>{if(count)await page.getByRole('button',{name:'Prepare a new run',exact:true}).click();});
await page.getByLabel('Run identifier',{exact:true}).fill('cancel-waiting');await page.getByLabel('Question',{exact:true}).fill('Another trip.');
Expand Down
6 changes: 3 additions & 3 deletions src/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {parseRunPolicy} from './runs';

const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const;

type Ready={inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null};
type Ready={usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null};
function Editor({api,space,catalog,initial,onSave,onDirty,inputsAvailable}:{api:ApiClient;space:string;catalog:AgentChoice[];initial:SavedPlan|null;onSave:(plan:SavedPlan)=>void;onDirty:()=>void;inputsAvailable:boolean}){
const first=catalog[0];
const newTask=(id:string):AgentTask=>({task_id:id,agent_id:first?.agent_id??'',model_id:first?.default_model??'',prompt:'',depends_on:[]});
Expand Down Expand Up @@ -74,7 +74,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){
if(!caps.features['agents.catalog']||!caps.features['agents.plans'])throw Error('Agent workflow configuration is not enabled on this server.');
const [catalog,page]=await Promise.all([api.request<unknown>('/v1/agents/catalog',options).then(parseCatalog),api.request<unknown>('/v1/agent-plans?limit=20',options).then(value=>parsePlanPage(value,status.space as string))]);
const maxParallel=caps.features['agents.parallel']?parseRunPolicy(await api.request<unknown>('/v1/agents/run-policy',options),status.space):1;
if(!controller.signal.aborted)setLoaded({api,data:{inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}});
if(!controller.signal.aborted)setLoaded({api,data:{usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}});
})().catch(error=>{if(!controller.signal.aborted)setError(error instanceof Error?error.message:'Agent configuration could not be loaded.');}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});
return()=>controller.abort();
},[api,enabled,refresh]);
Expand All @@ -95,7 +95,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){
{(()=>{
const props={api,inputsAvailable:data.inputsAvailable,space:data.space,catalog:data.catalog,initial:selection.plan,onDirty:()=>setDirty(true),onSave:(saved:SavedPlan)=>{setDirty(false);setSelection(current=>({...current,plan:saved}));setLoaded(current=>current?.api===api?{api,data:{...current.data,items:[saved,...current.data.items.filter(item=>item.plan.workflow_id!==saved.plan.workflow_id)]}}:current);}};
return (selection.plan?isHandoffPlan(selection.plan.plan):selection.handoff)?data.handoffsAvailable?<HandoffEditor key={selection.id} {...props}/>:<p role="alert">This server does not support handoff configuration.</p>:selection.plan&&isInteractivePlan(selection.plan.plan)&&!data.inputsAvailable?<p role="alert">This server does not support human input workflows.</p>:<Editor key={selection.id} {...props}/>;
})()}</div>{data.runsAvailable&&<RunPanel key={data.space} api={api} space={data.space} plan={selection.plan} dirty={dirty} maxParallel={data.maxParallel} handoffsAvailable={data.handoffsAvailable} inputsAvailable={data.inputsAvailable}/>} {error&&<p role="alert">{error}</p>}
})()}</div>{data.runsAvailable&&<RunPanel key={data.space} api={api} space={data.space} plan={selection.plan} dirty={dirty} maxParallel={data.maxParallel} handoffsAvailable={data.handoffsAvailable} inputsAvailable={data.inputsAvailable} usageAvailable={data.usageAvailable}/>} {error&&<p role="alert">{error}</p>}
</>}
</main>;
}
13 changes: 7 additions & 6 deletions src/agents/RunPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {UsageDetails} from './UsageDetails';
import {useEffect,useRef,useState} from 'react';
import {ApiError,type ApiClient} from '../api';
import {isHandoffPlan,isInputTask,isInteractivePlan,type SavedPlan} from './plans';
Expand All @@ -14,7 +15,7 @@ function message(error:unknown):string{
}
return error instanceof Error?error.message:'The run response is unavailable.';
}
function RunView({api,space,id,expected,onChanged,inputsAvailable}:{api:ApiClient;space:string;id:string;expected?:RunSubmission;onChanged:()=>void;inputsAvailable:boolean}){
function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable}:{api:ApiClient;space:string;id:string;expected?:RunSubmission;onChanged:()=>void;inputsAvailable:boolean;usageAvailable:boolean}){
const [version,setVersion]=useState(0),[request,setRequest]=useState<RunRequest|null>(null),[status,setStatus]=useState<RunStatus|null>(null),[result,setResult]=useState<RunResult|null>(null),[issue,setIssue]=useState(''),[busy,setBusy]=useState(false),[cancelling,setCancelling]=useState(false);
const [inputs,setInputs]=useState<RunInput[]>([]);
const active=useRef<AbortController|null>(null);
Expand All @@ -34,7 +35,7 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable}:{api:ApiClien
if(isInteractivePlan(original.plan)&&inputsAvailable){const prompts=parseInputPage(await api.request<unknown>(runAddress(id)+'/inputs',options()),original);if(controller.signal.aborted)return;verifiedInputs=prompts;setInputs(prompts);}
terminal=!progress.active_local&&(progress.status!=='running'||progress.outcome_unknown);
if(!progress.active_local&&['completed','verification_unavailable'].includes(progress.status)){
const verified=parseRunResult(await api.request<unknown>(runAddress(id)+'/result',options()),original);
const verified=parseRunResult(await api.request<unknown>(runAddress(id)+'/result'+(usageAvailable?'?include_usage=true':''),options()),original,usageAvailable);
if(isInteractivePlan(original.plan)){if(!inputsAvailable)throw Error('This server does not support human input workflows.');matchInputResults(verified,verifiedInputs);}
if(!controller.signal.aborted)setResult(verified);
}else if((progress.active_local||progress.status==='running')&&!progress.outcome_unknown){
Expand All @@ -47,7 +48,7 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable}:{api:ApiClien
await poll();
})().catch(error=>{if(!controller.signal.aborted){setIssue(message(error));setBusy(false);}});
return()=>{controller.abort();if(timer)clearTimeout(timer);};
},[api,space,id,version,expected,inputsAvailable]);
},[api,space,id,version,expected,inputsAvailable,usageAvailable]);
const canCancel=!!status&&(status.active_local||(!status.outcome_unknown&&['awaiting_input','registered','created'].includes(status.status)));
const cancel=async()=>{
if(cancelling||!canCancel)return;
Expand All @@ -67,7 +68,7 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable}:{api:ApiClien
{request&&<details open><summary>Original request · {request.plan.workflow_id} · Revision {request.revision} · {isHandoffPlan(request.plan)?`Up to ${request.plan.max_handoffs} handoffs`:`Up to ${request.max_parallel} simultaneous tasks`}</summary><p className="agent-output">{request.question}</p>{isHandoffPlan(request.plan)?<><p>Starting agent: {request.plan.root_agent}</p><ul>{request.plan.agents.map(agent=><li key={agent.agent_id}>{agent.agent_id} · {agent.model_id} · {agent.can_handoff_to.length?`May hand off to ${agent.can_handoff_to.join(', ')}`:'Must finish without handing off'}</li>)}</ul></>:<ul>{request.plan.tasks.map(task=><li key={task.task_id}>{task.task_id}: {isInputTask(task)?'Human input':`${task.agent_id} · ${task.model_id}`}{task.depends_on.length?` · Receives ${task.depends_on.join(', ')}`:''}</li>)}</ul>}</details>}
{request&&status&&inputsAvailable&&inputs.length>0&&<InputPanel api={api} request={request} status={status} items={inputs} onChanged={()=>{setVersion(n=>n+1);onChanged();}}/>}
{result?.outcome==='handoff_limit'&&<p className="agent-notice" role="status">The handoff limit was reached. These are partial results; no final answer was produced.</p>}
{result&&<div aria-label="Verified run results">{result.tasks.map(output=>output.kind==='human_input'?<article key={output.task_id}><h4>{output.task_id} · Human input</h4><p>Reply used by this workflow</p><div className="agent-output">{output.text}</div></article>:<article key={output.task_id}><h4>{output.task_id} · {output.agent_id} · {output.model_id}{result.finalTask===output.task_id?' · Final answer':''}</h4>{output.handoff_to!==undefined&&<p>{output.handoff_to===null?'Agent finished':`Handed off to ${output.handoff_to}`}</p>}<p>{output.source_status==='retained'?`${output.evidence_ids.length} retained evidence references`:'No retained evidence · Treat this as ungrounded model output'} · {output.model_calls} model calls · {output.tool_calls} tool calls</p><div className="agent-output">{output.text}</div></article>)}</div>}
{result&&<div aria-label="Verified run results">{result.tasks.map(output=>output.kind==='human_input'?<article key={output.task_id}><h4>{output.task_id} · Human input</h4><p>Reply used by this workflow</p><div className="agent-output">{output.text}</div></article>:<article key={output.task_id}><h4>{output.task_id} · {output.agent_id} · {output.model_id}{result.finalTask===output.task_id?' · Final answer':''}</h4>{output.handoff_to!==undefined&&<p>{output.handoff_to===null?'Agent finished':`Handed off to ${output.handoff_to}`}</p>}<p>{output.source_status==='retained'?`${output.evidence_ids.length} retained evidence references`:'No retained evidence · Treat this as ungrounded model output'} · {output.model_calls} model calls · {output.tool_calls} tool calls</p>{result.reusedTasks?.includes(output.task_id)&&<p>Saved task result reused</p>}{output.usage!==undefined&&<UsageDetails usage={output.usage}/>}<div className="agent-output">{output.text}</div></article>)}</div>}
{issue&&<p role="alert" className="agent-notice">{issue}</p>}
</section>;
}
Expand All @@ -85,7 +86,7 @@ function RunHistory({api,space,version,onSelect}:{api:ApiClient;space:string;ver
useEffect(()=>{const controller=new AbortController();active.current=controller;setItems([]);setAfter(null);void load(controller,null);return()=>controller.abort();},[api,space,version]);
return <section aria-label="Run history"><h3>Run history in {space}</h3><ul className="agent-run-list">{items.map(run=><li key={run.run_id}><button onClick={()=>onSelect(run.run_id)}>{run.run_id} · {run.workflow_id}<small>{run.status} · Revision {run.plan_revision}</small></button></li>)}</ul>{!busy&&!items.length&&!issue&&<p>No runs saved yet.</p>}{busy&&<p role="status">Loading runs…</p>}{after&&<button disabled={busy} onClick={()=>{if(active.current)void load(active.current,after);}}>Load more runs</button>}{issue&&<p role="alert">{issue}</p>}</section>;
}
export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inputsAvailable}:{api:ApiClient;space:string;plan:SavedPlan|null;dirty:boolean;maxParallel:number;handoffsAvailable:boolean;inputsAvailable:boolean}){
export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inputsAvailable,usageAvailable}:{api:ApiClient;space:string;plan:SavedPlan|null;dirty:boolean;maxParallel:number;handoffsAvailable:boolean;inputsAvailable:boolean;usageAvailable:boolean}){
const [runId,setRunId]=useState<string>(()=>crypto.randomUUID()),[parallel,setParallel]=useState(1),[question,setQuestion]=useState(''),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false),[issue,setIssue]=useState(''),[submitted,setSubmitted]=useState<RunSubmission|null>(null),[selected,setSelected]=useState<{id:string;version:number;expected?:RunSubmission}|null>(null),[history,setHistory]=useState(0),[lookup,setLookup]=useState('');
const active=useRef<AbortController|null>(null);
const supported=!plan||(isInteractivePlan(plan.plan)?inputsAvailable:!isHandoffPlan(plan.plan)||handoffsAvailable);
Expand All @@ -108,7 +109,7 @@ export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inp
{attempted&&<div className="agent-actions"><button disabled={busy} onClick={()=>inspect(runId)}>Check submitted run</button><button disabled={busy} onClick={()=>{setRunId(crypto.randomUUID());setAttempted(false);setIssue('');}}>Prepare a new run</button><span>A new run calls the selected models again.</span></div>}
{issue&&<p role="alert" className="agent-notice">{issue}</p>}
<form className="agent-run-lookup" onSubmit={event=>{event.preventDefault();try{inspect(lookup);setIssue('');}catch(error){setIssue(message(error));}}}><label>Find run by identifier<input value={lookup} maxLength={128} required onChange={event=>setLookup(event.target.value)}/></label><button>Open run</button></form>
{selected&&<RunView key={`${selected.id}:${selected.version}`} api={api} space={space} id={selected.id} expected={selected.expected} inputsAvailable={inputsAvailable} onChanged={()=>setHistory(n=>n+1)}/>}
{selected&&<RunView key={`${selected.id}:${selected.version}`} api={api} space={space} id={selected.id} expected={selected.expected} inputsAvailable={inputsAvailable} usageAvailable={usageAvailable} onChanged={()=>setHistory(n=>n+1)}/>}
<RunHistory api={api} space={space} version={history} onSelect={inspect}/>
</section>;
}
17 changes: 17 additions & 0 deletions src/agents/UsageDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {tokenUsageRows,type ToolTokenUsage} from './usage';

export function UsageDetails({usage}:{usage:ToolTokenUsage|null}) {
if (usage === null) return <p className="agent-usage-note">Token usage was not recorded for this saved task.</p>;
return <div className="agent-usage">
<table>
<caption>Reported token usage</caption>
<thead><tr><th scope="col">Category</th><th scope="col">Tokens</th><th scope="col">Reporting calls</th></tr></thead>
<tbody>{tokenUsageRows(usage).map(row => <tr key={row.label}>
<th scope="row">{row.label}</th>
<td>{row.tokens === null ? 'Unknown' : row.tokens.toLocaleString()}</td>
<td>{row.reportedCalls} of {row.modelCalls}</td>
</tr>)}</tbody>
</table>
<p className="agent-usage-note">Provider-reported counts for this task. Missing reports stay unknown. These counts are not a billing total.</p>
</div>;
}
Loading
Loading