Skip to content

v1.3.2

Choose a tag to compare

@cryxnet cryxnet released this 11 Oct 13:17
· 43 commits to main since this release

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_tool hooks got their arguments swapped and could not rewrite results -- CodeActionNode called post_tool(name, args, result, state) instead of the documented post_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 a context_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 polling queue_status(job_id) or a health check saw "running" until its iteration limit although the job had completed, the server never got the requests, and trace_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); the managed pattern'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 reason node used its 15 iterations (or the run reached max_agent_iterations) right after calling tools, the engine logged "Stuck node 'reason' — forcing graph end" and the run ended on that tool result, so result["messages"][-1].content was the raw tool output, such as a queue_status JSON, instead of an answer. Such a run now raises GraphIterationLimitError (new in promptise.engine, a GraphExecutionError) with the node, the limit and the tools whose results the model never saw (pending_tools); ExecutionReport.error carries the message. astream() and astream_events() raise it after their output, and astream_with_tools() ends with an ErrorEvent. 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 no ToolMessage was written for its tool_call_id (NodeResult.tool_calls recorded 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 own ToolMessage with that result, reported as reused the way ContextCompaction(reuse_results=...) reports it: trace_tools, the observer, callbacks (metadata["promptise_reused"]), engine hooks (on_tool_reused) and "reused": True in NodeResult.tool_calls. A call in a later turn, a later pass of a tool_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.
  • ToolNode calls were missing from astream_events() and astream_with_tools() -- a ToolNode emitted no on_tool_start / on_tool_end events, so in a custom graph that runs tools with one, a streamed run showed no ToolStartEvent / ToolEndEvent for its calls, reused calls included, and DoneEvent.tool_calls was empty. A ToolNode now streams each call with the same events and fields as a PromptNode: 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 an InjectedToolCallId argument 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 a ToolMessage with status="error", where it was "Error: MCPToolError: ..." without a status. A tool that handles its own ToolException and an unknown tool count as failed, a non-string result reaches the model as JSON (it was a Python repr), and every ToolMessage either node writes carries the tool's name. The calls of one turn still run one after another.
  • Engine hooks' pre_tool / post_tool never ran for ToolNode calls -- a hook that rewrites arguments, audits results or refuses a tool had no effect on calls a ToolNode made. They now run around each call exactly as for a PromptNode: pre_tool returns the arguments to use, post_tool the result the model sees, and a pre_tool hook that raises blocks the call (the tool does not run, no post_tool, the model gets "Error: <message>", and the call counts as failed). A repeat answered from the turn's earlier result gets on_tool_reused instead, 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 pydantic ValidationError, and a null for 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, after pre_tool hooks (an MCP tool, which has checked its own arguments since 1.3.1 and reports a rejected call to trace_tools and the observer, is left to do so). Invalid arguments do not reach the tool: the model gets the ToolArgumentError message from 1.3.1, each problem by argument path and an instruction to correct the call, in a ToolMessage with status="error"; the call counts as failed, streams as failed, is never reused, and callbacks get on_tool_error with the ToolArgumentError (code INVALID_ARGUMENTS). As for MCP tools, an explicit null for an optional parameter that cannot be null counts as not given. validate_inputs=False passes the arguments to the tool as they are.
  • The iteration-limit error named the tool "?" for a tool_selector graph -- when a run whose tool calls came from a ToolNode(tool_selector=...) hit an iteration limit, GraphIterationLimitError said "after a call to ? whose result the model never saw" and pending_tools was ["?"], 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 (the managed pattern) 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) or reuse_results=True for 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: idempotentHint says 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_tools prints ↺ Reusing an earlier result for tool: ..., the observer timeline and observe=True record it with reused: true in the metadata, LangChain callbacks get its start and end with metadata["promptise_reused"] (promptise.engine.nodes.REUSED_TOOL_METADATA_KEY), engine hooks get the new optional on_tool_reused(tool_name, args, result, state), NodeResult.tool_calls records it with "reused": True (it was "cached": True), and astream_with_tools() ends it with a ToolEndEvent whose new reused field is True. reuse_results round-trips through save_graph() / load_graph().

Full changelog: https://github.com/promptise-com/foundry/blob/main/CHANGELOG.md