Repository navigation
v1.3.2
A patch release for 1.3.1. Upgrade with pip install -U promptise.
The main fix: once a tool loop had 6 tool results, the default agent answered repeated identical tool calls from earlier results without running the tool, so an agent polling a job or health status kept seeing a stale value. Repeated calls now run the tool; reusing earlier results is an opt-in (ContextCompaction(reuse_results=...)) and always visible in traces. A run that hits an iteration limit in the middle of a tool loop now raises GraphIterationLimitError instead of returning a raw tool result as the answer.
Fixed
- Code-action:
post_toolhooks got their arguments swapped and could not rewrite results --CodeActionNodecalledpost_tool(name, args, result, state)instead of the documentedpost_tool(tool_name, result, args, state)and ignored what the hook returned, so a hook that redacts or truncates tool results had no effect on code-action agents. The hook now gets the documented arguments, and a returned string replaces the result the generated program receives and the run records. - A repeated tool call got a stale result once a tool loop compacted -- once a run had 6 tool results (the default ReAct agent's
context_scope="auto"switching to the ledger), and on every call of acontext_scope="ledger"node (agent_pattern="managed"), a call with the same tool and arguments as an earlier successful call got that earlier result back and the tool never ran. An agent pollingqueue_status(job_id)or a health check saw "running" until its iteration limit although the job had completed, the server never got the requests, andtrace_tools,observer, callbacks and engine hooks showed nothing for those calls. A repeated call now runs the tool. Answering a repeat from the earlier result is an explicit opt-in for tools whose result cannot change during a run (see Changed), and a call answered that way is reported, marked as reused. The ledger's note to the model now says each entry is the result as it was when the call ran and that the current value of something that changes, such as a status, can be fetched again (it said not to call a tool again for the listed results); themanagedpattern's prompt says the same. - A run stopped by an iteration limit in the middle of a tool loop returned a tool result as the answer -- when the ReAct
reasonnode used its 15 iterations (or the run reachedmax_agent_iterations) right after calling tools, the engine logged "Stuck node 'reason' — forcing graph end" and the run ended on that tool result, soresult["messages"][-1].contentwas the raw tool output, such as aqueue_statusJSON, instead of an answer. Such a run now raisesGraphIterationLimitError(new inpromptise.engine, aGraphExecutionError) with the node, thelimitand the tools whose results the model never saw (pending_tools);ExecutionReport.errorcarries the message.astream()andastream_events()raise it after their output, andastream_with_tools()ends with anErrorEvent. A limit reached after the model answered, or one the graph routes to an"error"transition or an__error__node, ends the run as before. ToolNode(deduplicate=True)treated calls from earlier runs as duplicates and left them without a result -- the node kept its record of past calls on the node object, which every run of the graph shares. A call with the same tool and arguments as any call the node had made before, in the same run, an earlier run on the same agent or a concurrent one, did not run, and noToolMessagewas written for itstool_call_id(NodeResult.tool_callsrecorded it as"DUPLICATE: identical call already made"). The model got no result for the call, which providers that need one result per call reject, and a status check the model repeated in a later turn never reached the tool. Deduplication now covers only the calls of one model turn, and its record belongs to the run, not the node. When the model asks for the same tool with the same arguments twice in one turn, the tool runs once and each repeat gets its ownToolMessagewith that result, reported as reused the wayContextCompaction(reuse_results=...)reports it:trace_tools, theobserver, callbacks (metadata["promptise_reused"]), engine hooks (on_tool_reused) and"reused": TrueinNodeResult.tool_calls. A call in a later turn, a later pass of atool_selector, or another run runs the tool, and a failed call is never reused. If the graph reaches the node again before the model's next turn, calls that already have a result are not run again.ToolNodecalls were missing fromastream_events()andastream_with_tools()-- aToolNodeemitted noon_tool_start/on_tool_endevents, so in a custom graph that runs tools with one, a streamed run showed noToolStartEvent/ToolEndEventfor its calls, reused calls included, andDoneEvent.tool_callswas empty. AToolNodenow streams each call with the same events and fields as aPromptNode:tool_index,duration_ms,success,reused, and for a failed call the error's summary. It runs a tool the same way too: as a tool call, so a tool with anInjectedToolCallIdargument works (it raised before), and an MCP tool error reaches the model as the server's error envelope with its code ({"error": {"code": "TOOL_ERROR", "message": ...}}) in aToolMessagewithstatus="error", where it was"Error: MCPToolError: ..."without a status. A tool that handles its ownToolExceptionand an unknown tool count as failed, a non-string result reaches the model as JSON (it was a Python repr), and everyToolMessageeither node writes carries the tool'sname. The calls of one turn still run one after another.- Engine hooks'
pre_tool/post_toolnever ran forToolNodecalls -- a hook that rewrites arguments, audits results or refuses a tool had no effect on calls aToolNodemade. They now run around each call exactly as for aPromptNode:pre_toolreturns the arguments to use,post_toolthe result the model sees, and apre_toolhook that raises blocks the call (the tool does not run, nopost_tool, the model gets"Error: <message>", and the call counts as failed). A repeat answered from the turn's earlier result getson_tool_reusedinstead, as before. The rules are now documented under Tool Hooks in the engine hooks guide. ToolNode(validate_inputs=True)did nothing -- the parameter (on by default, documented as "validate tool arguments before calling") was never used, so invalid arguments reached the tool and came back as a raw pydanticValidationError, and anullfor an optional parameter of a Python tool failed the call. The node now validates each call's arguments against the schema the model is offered for the tool, afterpre_toolhooks (an MCP tool, which has checked its own arguments since 1.3.1 and reports a rejected call totrace_toolsand theobserver, is left to do so). Invalid arguments do not reach the tool: the model gets theToolArgumentErrormessage from 1.3.1, each problem by argument path and an instruction to correct the call, in aToolMessagewithstatus="error"; the call counts as failed, streams as failed, is never reused, and callbacks geton_tool_errorwith theToolArgumentError(codeINVALID_ARGUMENTS). As for MCP tools, an explicitnullfor an optional parameter that cannot be null counts as not given.validate_inputs=Falsepasses the arguments to the tool as they are.- The iteration-limit error named the tool "?" for a
tool_selectorgraph -- when a run whose tool calls came from aToolNode(tool_selector=...)hit an iteration limit,GraphIterationLimitErrorsaid "after a call to ? whose result the model never saw" andpending_toolswas["?"], or named a tool from an earlier model turn when the model had answered before. It now names the tools of the trailing tool results, including those a selector called.
Changed
- Ledger compaction no longer answers repeated calls from earlier results by default -- since 1.1.0,
context_scope="ledger"nodes (themanagedpattern) served every repeated(tool, args)call from the earlier result, and so did the default ReAct agent ("auto") once its tool loop compacted. To keep that for lookups whose result cannot change during a run, opt the tools in:build_agent(context_compaction=ContextCompaction(reuse_results=["get_employee", "lookup_*"]))(tool names,*wildcards allowed) orreuse_results=Truefor every tool;PromptNode(compaction=...)takes it per node. It applies once the node compacts and never to a failed call. No MCP annotation turns it on:idempotentHintsays a repeated call has no further side effects, not that its result stays the same. A reused call is reported like any other, marked as reused:trace_toolsprints↺ Reusing an earlier result for tool: ..., theobservertimeline andobserve=Truerecord it withreused: truein the metadata, LangChain callbacks get its start and end withmetadata["promptise_reused"](promptise.engine.nodes.REUSED_TOOL_METADATA_KEY), engine hooks get the new optionalon_tool_reused(tool_name, args, result, state),NodeResult.tool_callsrecords it with"reused": True(it was"cached": True), andastream_with_tools()ends it with aToolEndEventwhose newreusedfield isTrue.reuse_resultsround-trips throughsave_graph()/load_graph().
Full changelog: https://github.com/promptise-com/foundry/blob/main/CHANGELOG.md