1515from ai_company .engine .loop_protocol import (
1616 ExecutionResult ,
1717 TerminationReason ,
18+ TurnRecord ,
1819)
1920from ai_company .engine .prompt import SystemPrompt , build_system_prompt
2021from ai_company .engine .react_loop import ReactLoop
3334 EXECUTION_ENGINE_TASK_TRANSITION ,
3435)
3536from ai_company .providers .enums import MessageRole
36- from ai_company .providers .models import ChatMessage , TokenUsage
37+ from ai_company .providers .models import ChatMessage
3738from ai_company .tools .invoker import ToolInvoker
3839
3940if TYPE_CHECKING :
5051_EXECUTABLE_STATUSES = frozenset ({TaskStatus .ASSIGNED , TaskStatus .IN_PROGRESS })
5152"""Task statuses the engine will accept for execution.
5253
53- CREATED tasks lack an assignee; terminal statuses (COMPLETED, CANCELLED,
54- FAILED) and BLOCKED/IN_REVIEW are not executable.
54+ CREATED tasks lack an assignee; terminal statuses (COMPLETED, CANCELLED)
55+ and BLOCKED/IN_REVIEW are not executable.
5556"""
5657
5758
@@ -146,15 +147,25 @@ async def run(
146147 )
147148
148149 start = time .monotonic ()
150+ ctx : AgentContext | None = None
151+ system_prompt : SystemPrompt | None = None
149152 try :
150- return await self ._execute (
153+ ctx , system_prompt = self ._prepare_context (
151154 identity = identity ,
152155 task = task ,
153156 agent_id = agent_id ,
154157 task_id = task_id ,
155- completion_config = completion_config ,
156158 max_turns = max_turns ,
157159 memory_messages = memory_messages ,
160+ )
161+ return await self ._execute (
162+ identity = identity ,
163+ task = task ,
164+ agent_id = agent_id ,
165+ task_id = task_id ,
166+ completion_config = completion_config ,
167+ ctx = ctx ,
168+ system_prompt = system_prompt ,
158169 start = start ,
159170 )
160171 except MemoryError , RecursionError :
@@ -174,6 +185,8 @@ async def run(
174185 agent_id = agent_id ,
175186 task_id = task_id ,
176187 duration_seconds = time .monotonic () - start ,
188+ ctx = ctx ,
189+ system_prompt = system_prompt ,
177190 )
178191
179192 async def _execute ( # noqa: PLR0913
@@ -184,19 +197,11 @@ async def _execute( # noqa: PLR0913
184197 agent_id : str ,
185198 task_id : str ,
186199 completion_config : CompletionConfig | None ,
187- max_turns : int ,
188- memory_messages : tuple [ ChatMessage , ...] ,
200+ ctx : AgentContext ,
201+ system_prompt : SystemPrompt ,
189202 start : float ,
190203 ) -> AgentRunResult :
191- """Prepare context, run the execution loop, record costs, and build result."""
192- ctx , system_prompt = self ._prepare_context (
193- identity = identity ,
194- task = task ,
195- agent_id = agent_id ,
196- task_id = task_id ,
197- max_turns = max_turns ,
198- memory_messages = memory_messages ,
199- )
204+ """Run the execution loop, record costs, and build result."""
200205 budget_checker = _make_budget_checker (task )
201206 tool_invoker = self ._make_tool_invoker ()
202207
@@ -369,11 +374,15 @@ async def _record_costs(
369374 agent_id : str ,
370375 task_id : str ,
371376 ) -> None :
372- """Record accumulated costs to the CostTracker if available.
377+ """Record per-turn costs to the CostTracker if available.
373378
374- Cost recording failures are logged but do not affect the
375- execution result — a successful run is never downgraded to
376- an error because of a recording failure.
379+ Each turn produces its own ``CostRecord``, preserving per-call
380+ granularity. Turns with zero cost and zero tokens are skipped.
381+
382+ Recording failures for regular exceptions are logged but do not
383+ affect the execution result. ``MemoryError`` and
384+ ``RecursionError`` propagate unconditionally as non-recoverable
385+ system errors.
377386 """
378387 if self ._cost_tracker is None :
379388 logger .debug (
@@ -384,45 +393,56 @@ async def _record_costs(
384393 )
385394 return
386395
387- usage = result .context .accumulated_cost
388- # Skip only when provably nothing happened (zero cost and zero
389- # tokens); a run with tokens but zero cost (e.g., a free-tier
390- # provider) is still recorded.
391- if (
392- usage .cost_usd <= 0.0
393- and usage .input_tokens == 0
394- and usage .output_tokens == 0
395- ):
396- logger .debug (
397- EXECUTION_ENGINE_COST_SKIPPED ,
396+ tracker = self ._cost_tracker
397+
398+ for turn in result .turns :
399+ # Skip only when provably nothing happened (zero cost and
400+ # zero tokens); a turn with tokens but zero cost (e.g., a
401+ # free-tier provider) is still recorded.
402+ if (
403+ turn .cost_usd <= 0.0
404+ and turn .input_tokens == 0
405+ and turn .output_tokens == 0
406+ ):
407+ logger .debug (
408+ EXECUTION_ENGINE_COST_SKIPPED ,
409+ agent_id = agent_id ,
410+ task_id = task_id ,
411+ turn_number = turn .turn_number ,
412+ reason = "zero cost and zero tokens" ,
413+ )
414+ continue
415+
416+ record = CostRecord (
398417 agent_id = agent_id ,
399418 task_id = task_id ,
400- reason = "zero cost and zero tokens" ,
419+ provider = identity .model .provider ,
420+ model = identity .model .model_id ,
421+ input_tokens = turn .input_tokens ,
422+ output_tokens = turn .output_tokens ,
423+ cost_usd = turn .cost_usd ,
424+ timestamp = datetime .now (UTC ),
425+ )
426+ await self ._submit_cost (
427+ record ,
428+ turn ,
429+ agent_id ,
430+ task_id ,
431+ tracker = tracker ,
401432 )
402- return
403-
404- record = CostRecord (
405- agent_id = agent_id ,
406- task_id = task_id ,
407- provider = identity .model .provider ,
408- model = identity .model .model_id ,
409- input_tokens = usage .input_tokens ,
410- output_tokens = usage .output_tokens ,
411- cost_usd = usage .cost_usd ,
412- timestamp = datetime .now (UTC ),
413- )
414- await self ._submit_cost (record , usage , agent_id , task_id )
415433
416434 async def _submit_cost (
417435 self ,
418436 record : CostRecord ,
419- usage : TokenUsage ,
437+ turn : TurnRecord ,
420438 agent_id : str ,
421439 task_id : str ,
440+ * ,
441+ tracker : CostTracker ,
422442 ) -> None :
423443 """Submit a cost record to the tracker, logging failures."""
424444 try :
425- await self . _cost_tracker . record (record ) # type: ignore[union-attr]
445+ await tracker . record (record )
426446 except MemoryError , RecursionError :
427447 logger .error (
428448 EXECUTION_ENGINE_COST_FAILED ,
@@ -438,17 +458,17 @@ async def _submit_cost(
438458 agent_id = agent_id ,
439459 task_id = task_id ,
440460 error = f"{ type (exc ).__name__ } : { exc } " ,
441- cost_usd = usage .cost_usd ,
442- input_tokens = usage .input_tokens ,
443- output_tokens = usage .output_tokens ,
461+ cost_usd = turn .cost_usd ,
462+ input_tokens = turn .input_tokens ,
463+ output_tokens = turn .output_tokens ,
444464 )
445465 return
446466
447467 logger .info (
448468 EXECUTION_ENGINE_COST_RECORDED ,
449469 agent_id = agent_id ,
450470 task_id = task_id ,
451- cost_usd = usage .cost_usd ,
471+ cost_usd = turn .cost_usd ,
452472 )
453473
454474 def _handle_fatal_error ( # noqa: PLR0913
@@ -460,9 +480,16 @@ def _handle_fatal_error( # noqa: PLR0913
460480 agent_id : str ,
461481 task_id : str ,
462482 duration_seconds : float ,
483+ ctx : AgentContext | None = None ,
484+ system_prompt : SystemPrompt | None = None ,
463485 ) -> AgentRunResult :
464486 """Build an error ``AgentRunResult`` when the execution pipeline fails.
465487
488+ When ``ctx`` and ``system_prompt`` are provided (i.e. context
489+ preparation succeeded before the failure), they are preserved in
490+ the error result so that accumulated state (conversation, task
491+ transition) is not lost.
492+
466493 If constructing the error result itself fails, the original
467494 exception is re-raised so it is never silently lost.
468495 """
@@ -475,13 +502,13 @@ def _handle_fatal_error( # noqa: PLR0913
475502 )
476503
477504 try :
478- ctx = AgentContext .from_identity (identity , task = task )
505+ error_ctx = ctx or AgentContext .from_identity (identity , task = task )
479506 error_execution = ExecutionResult (
480- context = ctx ,
507+ context = error_ctx ,
481508 termination_reason = TerminationReason .ERROR ,
482509 error_message = error_msg ,
483510 )
484- error_prompt = SystemPrompt (
511+ error_prompt = system_prompt or SystemPrompt (
485512 content = "" ,
486513 template_version = "error" ,
487514 estimated_tokens = 0 ,
0 commit comments