Skip to content

release: promote develop → master (v0.2.0)#21

Merged
ngoclam9415 merged 14 commits into
masterfrom
develop
Jul 7, 2026
Merged

release: promote develop → master (v0.2.0)#21
ngoclam9415 merged 14 commits into
masterfrom
develop

Conversation

@ngoclam9415

Copy link
Copy Markdown
Contributor

Release: develop → master (v0.2.0)

First release on the flattened develop → master gitflow. Replaces #18 (closed when stable was renamed → master). 14 commits, merge clean.

Highlights

Release machinery (landed on develop via #19, #20)

Versioning

  • develop pyproject = 0.2.0, master = 0.1.3 → develop is ahead, so bump-version-on-pr-to-master will not auto-bump (correct — this is an intentional minor).
  • On merge, release-on-merge-to-master tags v0.2.0 and creates the GitHub Release.

Commits (14)

48d430a chore(ci): flatten gitflow to develop → master (#20)
1c22655 fix(test): repair 6 failing tests on develop CI (#19)
7a479fc feat(agent): inject LLMProvider instance into STARAgent (#16)
105207f feat(llm): env var to force/disable Responses API for OpenAI/Azure (#15)
126a976 docs(task-resource): strengthen `resume` arg emphasis (#14)
183e893 fix(prompt): place scratchpad under session folder (#13)
6858b76 feat(agent,timeline): subagent factory pattern + per-session timeline reload (#12)
1e31c41 fix(timeline,tools): GPT-5 timeline robustness — reasoning-item drop + tool-batch isolation (#11)
799959f feat(llm,timeline): cross-turn reasoning state replay for gpt-5/o3/o4 (#10)
68cbad3 fix(llm): surface Azure/OpenAI gpt-5 thinking blocks via Responses API (#9)
4b2d713 feat(timeline): compression parity upgrades (P2+P3+P6) (#8)
cd00589 fix: enable LLM retry without fallbacks and surface SDK retries (#7)
183a3ba Merge with main/v0.1
a5f6211 feat: add embedding support to LLM providers (#3)

Pre-merge

  • check-branch-policy green (develop → master allowed)
  • lint-and-test green
  • Merge as merge commit (preserves PR history)

ngoclam9415 and others added 14 commits March 25, 2026 09:54
* ci: add auto-release workflow on merge to stable

Tags stable with current version, creates GitHub Release,
and bumps patch version on source branch. Supports bump:major
and bump:minor PR labels for controlling version increment.

* ci: split release workflow into bump-on-PR and tag-on-merge

- bump-version-on-pr-to-stable: auto-bumps patch version on main/*
  when PR is opened if source version <= stable version
- release-on-merge-to-stable: tags + creates GitHub Release only
- For major/minor bumps, manually edit pyproject.toml before PR

* chore: bump version to 0.1.2

* feat: add embedding support to LLM providers

Add centralized embedding functionality to dana.common.llm:

- EmbeddingResponse type, EmbeddingNotSupportedError exception
- embed()/embed_batch() on OpenAI, Gemini, Azure providers
- Embedder class with sync/async support, auto-provider selection
- Providers without embedding (Anthropic, Moonshot) raise clear errors
- Config: embedding_models per provider in config.json
- Unit tests (18 tests) and live integration tests

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Bug: An Azure APIConnectionError exited the STAR loop after ~7.5min
without any visible retry, despite the LLMCaller having retry logic.

Three converging bugs were fixed:

1. LLMCaller retry was opt-in via fallback_providers, so the primary
   provider had no retry path. Now retry/backoff always runs; failover
   stays gated on fallback_providers.

2. _is_transient_error did not classify "Connection error." as
   transient (no matching keyword) and could not see openai.APIConnectionError
   buried in __cause__. Added "connection" to the keyword list and an
   exception-class-name walk over the __cause__ chain.

3. STAR loop exited on any exception. Added one bounded retry per
   iteration for transient LLM errors (sync + async paths), with full
   tracebacks via exc_info=True on all error sites.

Observability: OpenAI/Azure/Moonshot providers now use a logging
httpx.AsyncClient that emits "LLM HTTP request" per attempt, surfacing
SDK-internal retries that were previously silent. New structured events:
llm_retries_exhausted, STAR transient retry warnings, and an
APIConnectionError-specific log line with traceback.

Tests: updated test_no_fallbacks_exception_propagates (split into
transient/permanent variants), added regression tests for the
connection-error classification and __cause__ chain detection. Full
unit + regression suites pass.
* feat(timeline): compression parity upgrades (P2+P3+P6)

Close three compression gaps vs OpenClaude while staying single-tier,
LLM-agnostic, KISS. Always uses len(str)/4 heuristic — zero provider
coupling. System+tools coverage via optional caller-supplied callbacks.

Phase 1 — Heuristic threshold (P3)
- New env knob DANA_COMPACT_TRIGGER_TOKENS (default 150000, clamp [8k, 2M])
- CompressedTimeline accepts optional system_tokens_fn / tools_tokens_fn
  callbacks; folded into needs_compression() estimate
- star_agent passes len(system_prompt)//4 + len(json.dumps(tools))//4

Phase 2 — Cheap client-side shrink (P6)
- cheap_shrink_tool_results() stubs old tool_result bodies to
  "[cleared for context budget]" preserving tool_call_id
- Predictive gate blocks mutation when savings insufficient (avoids
  vacuous summary over stubs)
- Idempotent via content-equality (no metadata flag)
- Opt-in via enable_cheap_shrink_tool_results; off by default

Phase 3 — Reactive compact + circuit breaker (P2)
- PromptTooLongError typed exception; provider mapping for Anthropic
  (invalid_request_error + "prompt is too long"), OpenAI-compat
  (context_length_exceeded), Gemini (post-hoc WARNING on MAX_TOKENS)
- llm_caller._invoke_llm_sync/async wraps with PTL catch →
  reactive_compact(attempt) → retry with 1s/3s backoff
- reactive_compact drops 5→10→20 oldest + _remove_forward_orphans +
  full re-summary (no shrink-bypass)
- Per-session circuit breaker with cooldown recovery
  (DANA_CIRCUIT_COOLDOWN_SECONDS, default 300s) + half-open probe
- Kill switch via DANA_DISABLE_REACTIVE_COMPACT=1
- star_agent._maybe_compress_timeline re-raises PTL explicitly

Phase 4 — Telemetry & polish
- CompressionLogFields TypedDict allowlist + new_compaction_id()
- AST-based test asserts log extra={} keys stay within allowlist

Known gaps (documented in review report, follow-up PRs):
- PTL retry closes over captured messages list; post-compact retry
  re-sends stale oversized payload
- Recent huge tool_result cannot be reclaimed (shrink keep_recent
  blocks it; reactive_compact drops oldest only)
- Multi-compression does not preserve prior summary text
- Stubbed content persisted across reload produces vacuous summaries

* fix(timeline): compression review blockers + snapshot persistence

Addresses the code review in
plans/reports/code-review-260420-1112-compression-parity-triggering.md.
Scope: two CRITICAL merge-blockers, one HIGH bug, and the user-requested
snapshot-based persistence. HIGH-2/HIGH-3 and MEDIUM/LOW items are
intentionally deferred.

CRITICAL-1 — stale messages on PTL retry (llm_caller):
  _invoke_llm_sync/async closed over `messages`, so after reactive_compact
  trimmed the timeline, retries re-sent the same oversized payload and the
  circuit opened on genuinely-recoverable sessions. Added a
  `messages_fn: Callable[[], list[LLMMessage]]` parameter threaded through
  call_llm → _call_with_failover → _invoke_llm_sync/async. After each
  reactive_compact, the factory is re-invoked so the retry observes the
  compacted payload. star_agent passes a factory that re-calls
  runtime.build_prompt. Backwards-compatible (parameter defaults to None).

CRITICAL-2 — unreclaimable giant tool_result:
  Single huge tool_result in the keep-recent window couldn't be stubbed
  (cheap_shrink skips recent) nor dropped (reactive_compact drops oldest),
  wedging sessions after one big call. Fixed at ingest time:
  maybe_dump_oversized_content writes bodies >50KB (env-tunable via
  DANA_TOOL_RESULT_DUMP_THRESHOLD_CHARS) to
  {session}/tool_results/{tool_call_id}.txt and replaces the timeline
  content with a compact marker that preserves tool_call_id. A new
  ToolResultDumpResource exposes a read_tool_result tool with
  offset/limit slicing; auto-wired into STARAgent (opt-out via
  DANA_DISABLE_TOOL_RESULT_DUMP_RESOURCE=1).

HIGH-1 — vacuous summaries on reload with stubs:
  _format_entries_for_compression now emits
  [Tool result id=X: previously cleared — content unavailable] instead
  of feeding the literal [cleared for context budget] stub back into
  the LLM, so re-summarization after reload is not dominated by
  "the agent cleared tool results".

New — snapshot-based persistence (user requested):
  timeline.json is frozen at the first compression. Each compression
  rolls timeline-after-compress-{ISO-ts}.json; subsequent saves within
  a generation update the same snapshot in place. Full audit retention
  — older snapshots are never deleted. Repository read and the
  serializer loader both prefer the newest snapshot and fall back to
  timeline.json, then legacy path. Reload rehydrates the active
  snapshot so a fresh process does not roll a new file on every save.

Tests: 4 new CRITICAL-1 tests (assert retry token count < first attempt),
14 new tool-result dump tests, 7 new snapshot persistence tests. Two
existing failover tests updated for the new messages_fn parameter. Full
suite: 1148 unit + 72 integration passing.

* fix(timeline): decouple context-window budget from compression trigger

`StarAgent` unconditionally aliased `max_context_tokens` as the compression
trigger, so any agent that set a context budget (e.g. `EnergyWasteAnalyst`
at 200k) transparently overrode `DANA_COMPACT_TRIGGER_TOKENS` — ops had no
reachable knob through the agent path.

Split the two concerns:

- `CompressedTimeline.__init__` gains a dedicated `max_context_tokens` kwarg.
  Trigger resolution: explicit → env → 150k default. Budget resolution:
  explicit → falls back to resolved trigger for callers that haven't split
  yet. `cutoff_when_token_reach` stays pinned to the trigger.
- `StarAgent.__init__` gains `compress_trigger_tokens: int | None = None`
  and threads the two knobs separately. `compress_trigger_tokens=None`
  (default) defers to the env var, which is the intended ops contract.

Regression guards cover: budget set alone leaves trigger on env, env wins
when only the budget is explicit, explicit trigger still beats env, and
legacy single-knob callers still alias (backward compat).

* fix(timeline): resume via load_from_entries must not clobber timeline.json

After compression rolls a snapshot, resuming a session via
`CompressedTimeline.load_from_entries(entries)` (without native_messages)
skipped the snapshot-state rehydration that `read_since` does. The save
path then fell through to `session_folder / "timeline.json"` and
overwrote it with post-compression state on every turn — leaving the
snapshot file frozen and the canonical `timeline.json` polluted.

The Honeywell Django caller (agent_service.py) takes exactly this path:
reads entries via snapshot-aware `read_session_entries`, then calls
`load_from_entries(entries)` without the `native_messages` arg, so the
load-side rehydration in `_try_load_native_messages_from_repository`
never runs.

Fix in `_resolve_snapshot_write_path`: when no active snapshot is
tracked but the session folder already contains one or more
`timeline-after-compress-*.json`, adopt the newest as the write target
and stamp `_active_snapshot_compression_at` from its filename. Symmetric
with `LocalTimelineRepository.read_session_entries` which already
prefers newest snapshot for reads.

Regression test (`test_resume_via_load_from_entries_adopts_newest_snapshot_on_save`)
mirrors the Honeywell caller: compress, fresh timeline, load_from_entries,
add entry, save, assert write landed in the snapshot and timeline.json
stayed frozen.

* refactor(timeline): decouple compression from filesystem via repo.list_sessions (GH-1)

Timeline serializer now goes through the repository interface only — no
direct file I/O, no Path/glob, no _events_path access. Compaction mints
sibling logical sessions {base}__compact__{ISO-ts} instead of rolling
snapshot files, making the compression feature usable with any
TimelineRepositoryProtocol implementation (including in-memory and
remote repos, not just filesystem-backed).

- Add list_sessions(prefix) to TimelineRepositoryProtocol + local impl.
- Add TimelineRepositoryDefaultsMixin so external repos without
  list_sessions get a no-op default (empty list → single-session fallback).
- Rewrite TimelineSerializerMixin (448→347 LOC) to use only save/
  read_session_entries/list_sessions.
- Drop native_messages persistence — recomputed from entries on load.
- Rename CompressedTimeline state: _active_snapshot_path →
  _active_compact_session_id, _active_snapshot_compression_at →
  _active_compact_compression_at.
- Use microsecond precision in compact session timestamps to prevent
  same-second collisions that would break audit retention.
- Add in-memory test fixture + parity test proving behavior matches
  across local-fs and in-memory backends.
- Keep LocalTimelineRepository._resolve_timeline_file_for_read for
  backward-compat reads of legacy timeline-after-compress-*.json.

Refs: GH-1
Plan: plans/260420-2141-GH-1-timeline-repository-compatibility/
Tests: 132/132 timeline+compression tests pass.

* fix(grep): auto-promote files_with_matches to content on single-file path

When SearchResource.grep receives a file path with the default
output_mode=files_with_matches, the engine returns only the bare path
— which LLM callers frequently misread as an empty result. Auto-promote
to content mode with an explanatory header note so the output actually
conveys what matched. Also refactors the AUTO-mode engine chain into a
for/break/else loop so the capture-then-prepend flow stays clean.
#9)

* fix(llm): surface Azure/OpenAI gpt-5 thinking blocks via Responses API

The OpenAI-compatible streaming wrapper listened for the event type
"response.reasoning.delta", which the openai SDK never emits. Real events
are response.reasoning_summary_text.delta and response.reasoning_text.delta,
so every reasoning delta was silently dropped. With reasoning.summary
unset by default, the API also wouldn't emit summary events at all, even
when a reasoning model was reasoning internally.

Separately, Azure unconditionally routed gpt-5/o3/o4 to /openai/responses,
which returns HTTP 400 BadRequest for api-version < 2025-03-01-preview.

Changes:
- openai_compatible_base: handle the real reasoning event names; default
  reasoning.summary="auto" so summary deltas stream; add
  _responses_api_supported() hook to gate routing on endpoint capability.
- azure: override _responses_api_supported() to require api-version date
  >= 2025-03-01, falling back to Chat Completions on older versions
  instead of crashing.
- tests: 23 new routing cases (version gate, prefix matching, config-flag
  override) plus updated reasoning-delta test to assert real SDK event
  names. 115/115 unit tests pass.
- scripts/verify-azure-thinking.py: live-Azure verification of streaming
  thinking chunks and non-streaming reasoning_tokens.

* fix(llm): route gpt-5/o3/o4 chat() through Responses API

Mirrors the routing already used by stream(). When _should_use_responses_api()
is True (reasoning model + endpoint capability), non-streaming chat() now
calls client.responses.create instead of client.chat.completions.create,
and parses the heterogeneous output[] array to populate
LLMResponse.reasoning_content with the model's reasoning summary text —
previously always None on this path.

Implementation:
- chat() becomes a thin dispatcher with shared error handling.
- _chat_via_chat_completions: existing path, extracted unchanged.
- _chat_via_responses: new path. Reuses _convert_to_responses_input and
  _prepare_tools_for_responses helpers. Builds reasoning={"summary":"auto"}
  by default so summary text actually streams back. Maps response.status
  + incomplete_details.reason to a Chat-Completions-style finish_reason
  (stop/tool_calls/length/incomplete). Maps usage input/output_tokens to
  prompt/completion_tokens for caller compatibility. Constructs
  ChatCompletionMessageToolCall objects for function_call items so
  downstream parsers (response_parser._to_tool_call_dicts) see the same
  Pydantic shape regardless of API path. json_mode maps to text.format.

Tests:
- 11 new unit tests in test_chat_via_responses.py covering reasoning
  extraction, content extraction (skipping refusals), tool-call shape,
  finish_reason mapping (stop/tool_calls/length/incomplete), usage
  mapping, dispatch routing (both directions), and json_mode mapping.
- 126/126 unit tests pass.

Live Azure gpt-5.2: chat() now returns reasoning_content with 1044+ chars
of summary text (was None). reasoning_tokens=455. Verified via
scripts/verify-azure-thinking.py.

* fix(llm): default reasoning.effort=medium for gpt-5/o3/o4

Without an explicit effort, gpt-5* sometimes skips reasoning entirely on a
given call — leaving reasoning_content empty and reasoning summary deltas
unfiring even with summary='auto'. Observed live with Azure gpt-5.2:
identical requests yielded 0 thinking chunks on some calls and 200+ on
others, purely model nondeterminism.

The wrapper is the right place to set this: it already routes
reasoning-model traffic to the Responses API, so it knows when reasoning
is the expected mode. Callers can still override (e.g. effort='low' for
cheaper turns).

Both _chat_via_responses and _stream_responses now setdefault:
  effort = "medium"
  summary = "auto"  (already there)

Tests: 3 new cases covering default behavior, effort override, and
summary override. 129/129 unit tests pass.

Live verification (Azure gpt-5.2 via DanaCodingAgent): wrapper now
deterministically returns 967 chars of reasoning_content. Tracking
confirms the wrapper-side issue is fully fixed; remaining agent-side
issue (star_agent.py:750-757 drops reasoning on direct-answer turns)
is a separate concern not addressed here.

Adds scripts/verify-thinking-persisted-via-coding-agent.py for
end-to-end inspection of timeline persistence.

* fix(agent): persist reasoning to timeline on direct-answer turns

When the model answered without invoking a tool, _record_think_results
took the no-tool-calls branch (star_agent.py:750) which only added
AGENT_RESPONSE — silently dropping the parsed reasoning. The else branch
(tool-calls path) already added AGENT_THOUGHTS for non-empty reasoning;
the no-tool-calls branch was the asymmetric outlier.

This dropped reasoning text on:
  - direct-answer turns (model solves the puzzle without tools)
  - the final turn of any tool-using session (after tools, model answers)

Affects all reasoning surfaces routed through codec_with_native_tool_use:
  - LLMResponse.reasoning_content (gpt-5/o3/o4 via Responses API,
    DeepSeek-R1, future Anthropic extended thinking)
  - <thinking> XML tags
  - JSON {"reasoning": ...} fields

Live verification (Azure gpt-5.2 via DanaCodingAgent):
  - direct scenario: 1373 chars reasoning persisted as AGENT_THOUGHTS
    (was 0)
  - tool scenario: AGENT_THOUGHTS entry now appears even on the final
    answer turn

For non-reasoning models, parsed.reasoning is None, the new block is
a no-op, behavior is unchanged.

129/129 unit tests pass.

* feat(llm): env-driven reasoning.effort with low default

Add OPENAI_THINKING_EFFORT and AZURE_THINKING_EFFORT env vars (plus
generic LLM_REASONING_EFFORT fallback) so operators can dial reasoning
cost/latency without code changes.

Precedence:
  1. caller's reasoning.effort kwarg
  2. provider env var (AZURE_THINKING_EFFORT / OPENAI_THINKING_EFFORT)
  3. LLM_REASONING_EFFORT
  4. "low"  (was "medium")

Default flipped to "low" so callers opt into deeper reasoning rather
than paying for medium implicitly. Invalid env values are logged and
ignored. Applies to both _chat_via_responses and _stream_responses.
…#10)

* feat(llm,timeline): cross-turn reasoning state replay (Option C, phases 1-4)

Persist raw reasoning items from the OpenAI Responses API in
TimelineEntry.metadata; on subsequent calls splice them back into
input[] when provider:model:endpoint matches, so gpt-5+ retains
structured reasoning state across turns instead of re-deriving it
from flattened assistant text. Reduces token churn and improves
reasoning continuity. Native-tool codec only.

Provider capture (Phase 1):
  - LLMResponse.reasoning_items + response_id (raw output[] items)
  - _chat_via_responses opts in to include=reasoning.encrypted_content
    with sticky one-shot 400 fallback for accounts/api-versions that
    reject the flag
  - endpoint_hash + fingerprint properties on Azure / OpenAI providers
    (sha256 of base_url, first 8 chars)

Agent persistence (Phase 2):
  - ParsedResponse carries reasoning_items + response_id; both codecs
    populate them
  - star_agent _record_think_results writes
    {reasoning_items, fingerprint, response_id} into AGENT_THOUGHTS
    metadata on the reasoning entry only (not the response/tool_call
    entries)

Codec replay (Phase 3):
  - LLMMessage gains reasoning_items / reasoning_fingerprint /
    response_id carriers
  - Timeline.to_llm_messages propagates entry metadata onto assistant
    messages; merge step skips when items present so 1:1 fingerprint
    provenance is preserved
  - prepare_messages threads carriers via underscore-prefixed keys
  - _convert_to_responses_input splices raw items before assistant
    message when fingerprint matches the active provider; carriers
    always stripped before API call
  - LLM_REASONING_REPLAY=0 kill switch (default ON, env-resolved
    per call)

Compression policy (Phase 4):
  - NativeMessage.to_llm_message propagates reasoning fields - closes
    a Phase 3 gap where CompressedTimeline-routed traffic silently
    dropped them
  - compression_engine.compress composes summary metadata from a single
    explicit dict so reasoning items never survive the boundary

Tests: 49 new unit tests across capture, agent persistence, codec
replay (match/mismatch/kill switch/tool-call ordering/multi-turn),
and compression policy. 422/422 across LLM + core + compression
suites green.

* fix(llm): strip output-only fields from replayed reasoning items + stale-id fallback

Surfaced by Phase 5 live verify (Azure gpt-5.2): replaying reasoning
items captured via model_dump() caused HTTP 400 because output-side
fields like 'status' aren't valid on input. Without a fallback, the
rejection silently killed every subsequent turn (user_message saved,
no agent_response).

Two fixes:

1. _serialize_output_item now whitelists input-side fields only:
   type, id, summary, encrypted_content. No more model_dump() leak of
   output-only metadata into replayed input[].

2. _chat_via_responses gains a one-shot stale-id / item-shape rejection
   fallback. On BadRequestError matching the rejection heuristic, strip
   reasoning items from input[] and retry once. Degrades to flat-text
   replay rather than failing the user's turn entirely.

Verify script (scripts/verify-reasoning-replay.py) confirms:
  - replay fires when ON (3 fires across turns 2-4)
  - kill switch (LLM_REASONING_REPLAY=0) cleanly disables replay
  - reasoning_items accumulate cumulatively turn-over-turn
  - all 4 turns complete cleanly with replay enabled
  - 329/329 unit tests still green

Findings recorded in plans/.../verify-260507-1905-reasoning-replay.md:
replay does NOT save tokens in steady-state - model reasons more
when prior state is present (~200-300 tokens per accumulated item).
Real benefit is reasoning continuity, not cost. Cost benefit
materializes when paired with ZDR/encrypted_content (smaller payload).

* test(llm): cover disk-resume reasoning replay path

User flagged the gap: Phase 5 verified in-process multi-turn replay,
but did not exercise the fresh-process disk-load → resume → replay
path. This commit closes it with both unit tests and a live verify.

Findings:

1. The replay machinery works end-to-end across a process boundary.
   Persisted timeline.json round-trips through the legacy + native
   load formats, metadata survives, items splice into Responses API
   input[] when fingerprint matches.

2. DanaCodingAgent does NOT auto-resume from disk - each new process
   gets a fresh empty session. Resume requires explicit re-injection
   of persisted entries via agent._timeline.load_from_entries(...).
   Documented in the verify report.

Tests (4):
  - Legacy-format disk resume → splice fires with persisted items
  - Native-format disk resume → NativeMessage.from_dict preserves
    metadata.reasoning_items end-to-end
  - Cross-provider fingerprint mismatch → no replay (carriers stripped)
  - Kill switch overrides matching fingerprint after resume

Live verify (scripts/verify-disk-resume-replay.py): fresh process
loaded a 3-item timeline, replayed all 3 into input[] on the next
turn (1 splice fire), LLM call completed.

333/333 unit tests pass.
…+ tool-batch isolation (#11)

* fix(timeline): persist reasoning items on empty-summary turns

GPT-5/o3/o4 turns return a reasoning item (rs_… + encrypted_content)
with an empty summary on low-summary turns — typically single-call
tool continuations. The AGENT_THOUGHTS gate in _record_think_results
keyed on summary text, so these turns produced no timeline entry and
the encrypted reasoning item was silently dropped.

On resume the affected turns replay with no reasoning state, breaking
cross-turn reasoning continuity for GPT-5/o3/o4.

Gate now keys on reasoning_items presence, not summary text. Entry is
emitted with empty content when only the item is present; metadata
still carries the item for replay.

Add TestEmptySummaryReasoningPersistence covering tool-call and
direct-answer branches plus the no-items negative case.

* fix(tools): isolate tool-call failures within a batch

A dispatch-phase exception (registry getattr, name parsing, object
lookup) escaped before the inner try block in _execute_single_call and
_execute_single_call_async. In the async path it propagated out of
asyncio.gather, discarding the entire batch's results — including calls
that succeeded.

The TOOL_CALL entry still recorded N tool_call_ids, so the next OpenAI
turn 400s on the unanswered tool calls.

- Wrap each single-call dispatcher in one outer guard covering dispatch
  and execution; both are now non-raising. Removes the redundant inner
  try blocks.
- execute_tools_async: asyncio.gather(return_exceptions=True) and
  convert any escaped exception to an error result — defense-in-depth.

Every tool_call_id in a batch now always gets a result.

Add isolation tests covering async and sync paths: a failing call
yields an isolated error result, siblings succeed, tool_call_ids
preserved.
… reload (#12)

* feat(agent,timeline): subagent factory pattern + per-session timeline reload

TaskResource dispatches to agent factories instead of shared instances.
Each Task call constructs a fresh agent with a disjoint object graph
(timeline, star loop count, session id, EventLog), eliminating both the
concurrent-same-type state corruption and the sequential timeline-
accumulation bug. Legacy instance registration still works but warns.

set_session_id is now a real context boundary: it flushes the outgoing
session, rebuilds the timeline via the extracted _build_timeline() (which
resets all session-scoped state including compaction-tracking fields), and
rehydrates from disk via the new CompressedTimeline.rehydrate() wrapper over
read_since. Unknown session yields an empty timeline; same id is a no-op.

- task_resource.py: factory registration, _descriptions cache, per-spawn
  construction, notifiable propagation at spawn; drop _get_agent_tools
- star_agent.py: extract _build_timeline(); rewrite set_session_id
- timeline_serializer.py: add CompressedTimeline.rehydrate()
- dana_coding_agent.py: register explore subagent as functools.partial
- tests: 9 TaskResource + 6 set_session_id unit tests

* fix(resource): mark TaskResource session failed on aquery exception

Previously a sub-agent whose aquery() raised left its session stuck at
status='running', so task_output reported it running indefinitely. task()
now catches the exception, records status='failed' plus the error, and
re-raises so the caller still observes the failure. task_output surfaces
the stored error for failed sessions.

Addresses code-review finding N3.

* refactor(agent): rename _compress_timeline attr to _compression_enabled

The persisted config flag sat one line above _timeline with a near-
identical name, reading as if it held a timeline. Rename to match
CompressedTimeline.compression_enabled. Constructor param unchanged.

* feat(agent): reload_timeline flag to opt out of per-session timeline reload

set_session_id gains reload_timeline (default True). When False it is a
pure relabel — the current in-memory timeline is kept and carried into the
new session id instead of being flushed, rebuilt, and rehydrated. This lets
a subclass that seeds its own timeline (e.g. a persona entry before the STAR
loop) keep that timeline across a session switch.

Gating only the rehydrate() call is insufficient — the _build_timeline()
rebuild also discards the caller's timeline — so the flag gates the whole
reload.

Threaded through aquery, aquery_stream, and aconverse (-> Communicator.
aconverse -> aquery). Default True everywhere preserves the session-boundary
contract for subagents and existing callers; opt out with reload_timeline=
False. Directed @agent messages keep the default.

* feat(agent): default reload_timeline to False; TaskResource opts in to True

Flip the reload_timeline default to False across set_session_id, aquery,
aquery_stream, and aconverse. Most callers — including subclasses that seed
their own timeline before the STAR loop — manage their own context, so a
pure relabel is the safer default.

TaskResource.task() now passes reload_timeline=True explicitly: a sub-agent's
session_id is a hard context boundary, so each spawn gets a disjoint,
disk-accurate timeline (fresh for a new session, rehydrated on resume).

Tests exercising the reload path updated to pass reload_timeline=True.

* refactor(agent): replace reload_timeline flag with public resume()

reload_timeline was a flag argument threaded through aquery, aquery_stream,
aconverse, and communicator.aconverse, toggling two distinct behaviors
(pure relabel vs. flush+rebuild+rehydrate). Replace it with an explicit
STARAgent.resume(session_id) wrapper over set_session_id(reload_timeline=True);
the query methods now always relabel. TaskResource calls agent.resume() per
spawn, then aquery(). set_session_id stays the internal primitive.

* fix(timeline): map SUB_AGENT_RESPONSE with tool_call_id to role=tool

SUB_AGENT_RESPONSE defaulted to role=assistant and dropped tool_call_id,
leaving the prior assistant tool_calls unanswered on resume — OpenAI 400
"tool_call_ids did not have response messages". Now SUB_AGENT_RESPONSE with a
tool_call_id normalizes to role=tool (native tool flow); without one it stays
assistant (legacy XML flow).

* fix(llm): drop redundant thought text when native reasoning replayed

When an agent_thoughts entry carries reasoning_items and the provider
fingerprint matches, _convert_to_responses_input spliced the native
reasoning item AND re-emitted the same thought as an assistant message.
The duplicate text is redundant with the item's summary+encrypted_content
and trips Azure's invalid_prompt / Prompt Shield filter, which scans
message-role content but not reasoning.summary.

Blank the assistant content on the native-splice path (non-tool-call
messages only); the fallback path keeps flat text for cross-provider
replay. Fixes invalid_prompt rejection replaying atlas-q33 timeline.
…alogue (#14)

`resume` is the only mechanism for multi-turn dialogue with a subagent, but
the calling agent often skips it — spawning fresh subagents that lose all
prior context. Promote it from one bullet in `Usage notes` to a dedicated
`=== CRITICAL ===` section in the dynamic tool description, with an explicit
NEW-vs-EXISTING decision rule. Expand the placeholder docstring's `resume:`
arg from one line to a paragraph covering channel role, failure mode of
omission, and when to legitimately omit.

Pure docstring change — no behavioral or API impact.
Add OPENAI_USE_RESPONSES_API / AZURE_USE_RESPONSES_API (plus generic
LLM_USE_RESPONSES_API fallback) to override Responses API routing without
editing config.json.

Precedence: provider env > generic env > use_responses_api config flag >
model-prefix auto-detect. Resolved at call time so changes apply without
restart. Explicit override bypasses the endpoint capability gate (mirrors
config-flag semantics); warns when forced on against an unsupported
endpoint (e.g. Azure api-version < 2025-03-01). Invalid values logged and
ignored, falling through to auto-detect.
* docs(spec): design for injecting LLMProvider instance through STARAgent

* docs(spec): use dedicated llm_provider_instance param, keep llm_provider as str

* docs(spec): document runtime/LLMCaller sink mechanics and set_llm necessity

* feat(rlm): accept injected LLM instance and add set_llm

* feat(ltmemory): forward injected LLM to RLMResource and add set_llm

* feat(agent): inject LLMProvider instance via llm_provider_instance + set_llm_provider

* fix(agent): sync _llm_config on re-point and cover ltmemory sink in test

- _apply_llm_provider now writes _llm_config alongside _llm_client so
  the lazy llm_client property cannot silently revert to the old provider
- Add divergence-warning comment in ctor instance-handling block
- Strengthen test_set_llm_provider_repoints_all_sinks: construct agent
  with ltmemory_path and assert Sink 3 (_ltmemory._rlm._llm.provider)

* test(agent): cover injected-provider ltmemory path and legacy lazy regression

* fix(agent): fan llm_client setter to ltmemory sink; cover set_llm_provider string path
Two unrelated groups, both pre-existing on develop:

1. tests/unit/core/test_inject_llm_provider.py (3 failures)
   Tests pass llm_provider="openai"/"anthropic" (string) which eagerly builds
   a real provider requiring OPENAI_API_KEY/ANTHROPIC_API_KEY. They only check
   identity wiring and never call the LLM. Add an autouse fixture that supplies
   dummy env keys so construction succeeds offline.

2. tests/unit/test_llm_providers.py::TestOpenAIReasoningTokens (3 failures)
   Thinking models (gpt-5*) route through the Responses API since #9/#15, but
   these tests still mocked client.chat.completions.create — so the awaited
   call hit an un-mocked MagicMock ("can't be awaited"). Rewrite to mock
   client.responses.create with a Responses-API-shaped response (output items
   + usage.output_tokens_details.reasoning_tokens) via a shared helper.
Drop the develop → main/<version> → stable stabilization layer (solo
maintainer; the ceremony isn't earning its keep). New release train is
develop → master, with master as the single release branch.

Changes:
- branch-policy.yml: allow only develop/hotfix → master (was stable ←
  main/hotfix). Triggers on PRs to master.
- bump-version-on-pr-to-{stable→master}.yml: trigger on PR to master,
  gate on head_ref == 'develop'. Compares develop vs master; auto-bumps
  patch only if develop isn't already ahead (manual minor/major bumps
  set on develop pass through).
- release-on-merge-to-{stable→master}.yml: trigger on merge to master,
  checkout master, tag vX.Y.Z + GitHub Release targeting master.
- pr-lint-and-test.yml: trigger on [develop, master] (was
  [develop, stable, main, main/**]).
- docs/branching-strategy.md: rewrite for the two-branch model.
- pyproject.toml: 0.1.3 → 0.2.0 (next release is a minor bump).

Follow-up (not in this PR): rename the stable branch → master via
`gh api .../branches/stable/rename`. The existing ruleset is keyed on
~DEFAULT_BRANCH so it auto-covers master without reconfiguration.

Orphaned asset: docs/assets/gitflow-vertical-branching.svg still depicts
the old 5-branch model and is no longer referenced — safe to delete later.
@ngoclam9415 ngoclam9415 merged commit 833ab68 into master Jul 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant