Changelog¶
All notable changes to anchor are documented here. The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.
[Unreleased]¶
[0.2.0] - 2026-09-08¶
Breaking¶
TimeoutErrorrenamed toLLMTimeoutError(anchorandanchor.llm): the old name shadowed the builtin, sofrom anchor import *or anexcept TimeoutErrorafter the import silently changed meaning. Catch provider timeouts withLLMTimeoutError(orProviderError); the builtin keeps meaning asyncio/socket timeoutsSimpleGraphMemoryis gone; the graph isanchor.graph.KnowledgeGraphover aGraphStore(roadmap #4): nodes carry a canonical key (normalize_key: NFKC + casefold + whitespace→_, so"Project X"and"project x"are one node), edges carry a free normalizedrelation, an optionalfact,provenance(extracted/inferred/ambiguous),confidence,evidence(ContextItemids — the single currency; for memory the item id isMemoryEntry.id) and bi-temporal validity (valid_from/valid_toworld time,created_at/invalidated_attransaction time).link_memory(entity, memory_id)→link_item(node, item_id, namespace="/"),add_relationship→add_edge(source, relation, target, evidence=...),get_related_memory_ids→related_items,get_related_entities→neighbors. Obsolete edges are invalidated, never deletedMemoryRetrieverAdapteritems carryid=MemoryEntry.idinstead of a fresh uuid — the memory id is the item id (one currency), so graph evidence and RRF fusion line up with what memory returnsgraph_retrieval_step(graph: KnowledgeGraph, ...)takes the new graph and gainsscope=; the walk honors the scope published by the running agent turn (intersected with the static one) — memory reached through the graph can no longer escapeAgent.with_scope()Retriever/AsyncRetrieverprotocols takescope: every built-in retriever honorsretrieve(..., scope=RetrievalScope)— composites (HybridRetriever,AsyncHybridRetriever,RoutedRetriever) forward it,SparseRetrievermasks out-of-scope documents before the top-k cut,SharedSpaceRetrieverfilters,MemoryRetrieverAdapterapplies it to the root namespace. A custom retriever without the keyword keeps working while no scope is active and fails loudly under one — never a silent widening.retriever_step/async_retriever_stepintersect their staticscopewith the scope published by the running agent turn- Vault names are validated at every mount (
validate_vault: non-empty, no/, no:) — store constructors andanchor index/query --vaultnow raise/exit on a bad name instead of writing rows that cannot be read back SqliteVecVectorStore(db, dimensions=None): the dimension is read from an existing index and a declared value that disagrees with the file raisesValueErrorbefore any DDL (it used to rebuild the index at the wrong dimension and strand every embedding)- Redis context keys moved to
ctxv:{vault}:{id}/ctxv-ids:{vault}: the__default__store migrates pre-vaultctx:keys in place on first use (RENAMENX, idempotent); the read-only legacy fallback is gone PostgresConnectionManagersetshnsw.iterative_scan=relaxed_orderas a pool session default (pgvector ≥ 0.8; a placeholder on older versions) — every vault-bound search is a filtered HNSW search, so recall no longer stops atef_searchcandidates- Tool results are capped by default: results (and error texts) longer than ~10k tokens are truncated head+tail with a marker before entering the messages — the loop finally calls the truncation the token-budget story always implied. Configure agent-wide with
Agent(tool_result_max_tokens=...)(Nonedisables), per tool withAgentTool(max_result_tokens=...); subagent results are exempt (bounded by the child's own loop; validated JSON arrives whole) - Async tool execution schedules by side effect: consecutive
AgentTool(read_only=True)calls run concurrently (cap 10); every undeclared tool is treated as a write and runs alone — two writes can never overlap. This serializes tools that ran in parallel before, including all MCP tools (readOnlyHintmapping is a tracked follow-up) — declare your side-effect-free toolsread_only=Trueto regain fan-out. Subagent dispatch (task/as_tool) stays parallel, now capped at 10 concurrent - New
stopped_by="stuck": repeating an identical (tool, args, result) call across consecutive rounds first gets the model a corrective nudge (3 identical errors / 4 identical results); repeating again ends the turn with a graceful wrap-up round, same contract asusage_limit— no exception. A stuck child returns a marked partial result to its parent UsageLimitsis now run-wide, not per-turn: the agent that starts a turn with limits creates a shared pool and every subagent spawned during it (task/as_tool) debits the same budget — an orchestrator'stotal_tokens_limitnow sees its children's spend. A child cut mid-run wraps up gracefully and returns a marked partial result; a child may carry its own narrower per-turn limits (SubagentDefinition(usage_limits=...)).UsageLimitReached.used/limitwiden toint | floatand the event gainskind="cost"andscope("run"pool vs"turn"own-limit)- New
stopped_by="output_missing": a structured-output turn whose final round still produced no validfinal_resultends gracefully with this verdict instead of raising from inside the loop — the loop always yields a terminalTurnFinished, andrun()/arun()are the single place that raises (chat/streamconsumers see the verdict, not an exception) Agentconstructor:clientparameter replaced withllm: LLMProviderandfallbacks: list[str]AgentTool: removedto_anthropic_schema(),to_openai_schema(),to_generic_schema(); replaced with unifiedto_tool_schema() -> ToolSchema
Added¶
extra_bodyon OpenAI-compatible providers (openai,grok,openrouter,ollama,litellm): per provider (create_provider(..., extra_body={...})) and per call, merged key by key with the call winning — body params the SDK has no field for (OpenRouterprovider/reasoning, vendor extensions)- Real usage on the OpenAI-compatible stream: streaming asks for the final usage-only chunk (
stream_options.include_usage) and the parsers readprompt_tokens_details.cached_tokensintoUsage.cache_read_tokens;OpenRouterProviderrequestsusage: {"include": true}by default so the billedusage.costlands inUsage.total_cost→RoundUsage.cost_usd→UsageLimits.cost_limit(rounds onopenrouter/*used to be tokenizer estimates at $0) - LLM memory consolidation (roadmap #5, opt-in):
LLMExtractor(llm)turns the recent user/assistant turns into facts in one call;LLMConsolidator(llm, embed_fn=None)is the update phase of the mem0 paper — one call decidesADD/UPDATE/DELETE/NONEper fact against the most similar memories (indexed, validated targets; unknownupdate→ADD, unknowndelete→ ignored, omitted fact →ADD; malformed answer →ADDfor all, with a warning), after cheap gates: exact hash →NONEand, withembed_fn, a fact with no neighbour abovenew_threshold→ADD, both without a model call.UPDATEkeeps the id, recomputes the hash and recordsmetadata["previous_content"].MemoryManager(extractor=, consolidator=, extract_window=10, remember_every=1, callbacks=)gainsremember()(explicit; returns the operations; fires the until-now silenton_extraction/on_consolidation) andafter_turn()(a turn counter; failures logged, never raised);Agentcallsafter_turn()after every completed turn — never on an abandoned stream, and off the event loop inastream(asyncio.to_thread).MemoryEntry.invalidate(by=)is the soft delete;MemoryGarbageCollector(retention=timedelta)keeps invalidated facts as history that long. A consolidation golden set (anchor.evaluation.consolidation:ConsolidationCase,evaluate_consolidator,load_consolidation_set; 48 cases intests/fixtures/consolidation_golden.jsonl) measures state, size and stale-fact probes;assert_metric_floornow takes any report withmean()and per-casemetrics - Communities, hubs and docs (roadmap #4, phase E):
KnowledgeGraph.communities()partitions the visible subgraph — a deterministic Louvain in the core, Leiden through igraph with the new[graph]extra — cached on the store'sversionso derived data is never persisted or stale;anchor graph communities; pure-Pythonlouvain/modularityinanchor.graph.algorithms. New docs: the Knowledge Graph guide and API page - LLM graph extraction and memory in the graph (roadmap #4, phase D, opt-in):
LLMGraphExtractor(llm)turns a chunk into entities (name, type, aliases) and typed relations (free normalizedrelation,fact,provenance,confidence) in one model call, provider injected, fail-soft;GraphIndexer.index_entries()indexesMemoryEntryobjects under their own id;MemoryManager(graph=GraphIndexer(...))keeps the graph in step with the persistent store (add indexes, update re-extracts, delete and clear unlink) - Persistent knowledge graph + CLI (roadmap #4, phase C):
SqliteGraphStoreand the asyncPostgresGraphStore(vault-keyed tables, a partial unique index on the live(source, relation, target), evidence and item namespaces in their own tables,COLLATE "C"on Postgres) — the visibility rules live once inanchor.storage._graph_sqland every backend answers exactly likeInMemoryGraphStore.anchor index --graphbuilds the graph from wikilinks and note structure next to the index;anchor graph query|path|explain|hubs|backlinksnavigate it under--vault/--include/--exclude - Graph retrieval and deterministic extraction (roadmap #4, phase B):
GraphRetriever(a first-classRetriever: seeds = nodes the query mentions via the graph's alias-aware matcher or anentity_extractor, plus optional dense passage seeds from aVectorStore; personalized PageRank scores items; canonical ids so RRF fusion inHybridRetrieverlines up);WikilinkExtractor(full Obsidian grammar —[[note]],|alias,#heading,#^block,![[embed]], basename resolution) andStructureExtractor(the chunk's note as a node, frontmatteraliases/tags) behind theGraphExtractorprotocol;GraphIndexer(add-only, idempotent — reindexing merges evidence, removal isunlink_item).evaluate_retrieverandABTestRunner.rungainscope=. A hand-made 40-note wiki corpus with a 30-query golden set (tests/fixtures/graph_corpus) gates graph vs hybrid+RRF vs fusion - Knowledge graph (roadmap #4, phase A):
GraphNode/GraphEdge/Subgraphmodels, theGraphStoreprotocol (vault-bound at construction like every store) withInMemoryGraphStore, andKnowledgeGraph—neighbors,backlinks,path,explain(relation + fact + provenance + evidence per hop),related_items,query(personalized PageRank over the entity–item bipartite graph, HippoRAG-2 style; scores land on items, no LLM on the read path),hubs(degree). Every read takesscope(aRetrievalScope) andas_of. Scope semantics: a node without a visible evidence item does not exist for that scope (not innodes,neighbors,backlinks,path,explain,queryorhubs) and is never crossed — a wall, not a bridge; an edge whose evidence is all hidden is hidden too. Pure-Pythonanchor.graph.algorithms(adjacency,bfs,shortest_path,degree,personalized_pagerank) - Vaults, namespaces and
RetrievalScope(roadmap #3): two scoping levels over everything anchor retrieves. A vault is a hard isolation mount bound at store construction (SqliteVecVectorStore(db, vault="dnd")— vec0PARTITION KEY; indexed column + composite(vault, id)keys on SQLite/Postgres; key path on Redis): callers never write the boundary into a query, and the same id coexists across vaults. A namespace is a hierarchical materialized path on everyContextItem(/campanha-1/sessoes), pre-filtered in the index via boundary-aware prefix ranges (neverLIKE; on vec0 through a pushed-down rowid subquery — a bareORin the KNN silently post-filters).RetrievalScope(include=..., exclude=...)is navigation only — exclude always wins,intersect()only narrows;Agent.with_scope()publishes it per tool call (the budget-pool window pattern) so subagents inherit parent∩own and can never widen;SubagentDefinition.scopenarrows declaratively.rag_tools({"juridico": r1, "notas": r2})mounts named vaults — the model picks among mounted names only, an unmounted vault is an error result. Thewherefilter gains the operator core$eq $ne $in $nin $gt $gte $lt $lte(unknown operators raise). CLI:anchor index --vault --namespace,anchor query --vault --include --exclude,anchor migrate; pre-vault databases migrate in place under the__default__sentinel (vec0 rebuilt by blob copy, no re-embedding). pgvector uses 0.8+ iterative scans when available (older servers degrade to today's behavior); sqlite-vec pin raised to>=0.1.9(TEXT-metadata delete bug in 0.1.6–0.1.8) claude_cliLLM provider ([claude-cli]extra): runs any anchorAgenton a Claude Code subscription (Pro/Max OAuth orclaude setup-token) viaclaude-agent-sdk— no API key. Tool calling round-trips through an in-process MCP server plus aPreToolUsehook returningpermissionDecision: "defer", which stops the CLI turn without executing and surfaces a normalToolCall/StopReason.TOOL_USEto the loop; mandatory overhead guards (setting_sources=[],strict_mcp_config=True) cut per-call prompt cost from ~178k to ~280 tokens; the CLI's own agentic tools (builtin_tools) are opt-in with a prompt-injection warning; billed cost is reported into usage accountingUsageLimits.cost_limit(USD): hard cost cap on the shared pool. Rounds are priced from, in order: provider-reported billed cost (claude_clisends it), the runtime-overridableanchor.llm.pricing.MODEL_PRICINGtable, and genai-prices (pip install astro-anchor[pricing]); an unpriced model warns once and debits $0 (tokens still count).RoundUsage.cost_usdrecords the per-round price- Aggregated multi-agent diagnostics:
TurnDiagnostics.children(ChildTurn: tool call id, subagent name, the child's own diagnostics — including a machine-visiblestopped_byfor partial results) plusrun_total_tokens/run_total_tool_calls/run_total_cost_usd. Children that die mid-turn (provider error, timeout) still appear with the accounting they accrued - Call-time nesting guard: subagent runners re-check the no-nesting rule on every dispatch, closing the registration-order bypass (
as_tool()first,with_subagentson the wrapped agent later) that could silently build a 3-level tree - Approval / human-in-the-loop (inline):
Agent.with_approval(callback)pauses a tool call until the callback returns anApprovalDecision(deny becomes anis_errortool result carrying the reason; approve may rewrite the input). Triggered byAgentTool(requires_approval=True)(also on@tool) or a pre-hook answeringHookResult(decision="ask"); fails closed without a callback. Async callbacks pause theastream/achatturn indefinitely — approval timeouts are the application's choice - Structured output on the main run:
Agent.with_output_model(Model)+run()/arun()return a validated instance. Default tool mode adds a syntheticfinal_resulttool and forcestool_choice="any"so the model cannot stop in plain text; invalid arguments retry through the loop's own error-result mechanic (max_output_retries).mode="prompted"reuses the subagent validate-and-retry path. Output JSON also lands inTurnFinished.output/agent.last_output - Memory tool (
memory_20250818-compatible, any provider):Agent.with_memory_tool(path)registers amemorytool (view/create/str_replace/insert/delete/rename) overFileMemoryBackend— reference-compatible result strings, strict/memoriescontainment (URL-encoded traversal rejected), protected root, and the memory protocol appended to the system prompt - MCP bridge gaps:
MCPServerConfig.defer_tools=Truekeeps a server's tools out of the prompt untilsearch_toolsloads them; MCP tool names that shadow existing tools now fail loudly at connect; prompts/resources are finally reachable —MCPClientPool.all_prompts/get_prompt/all_resources/read_resourceandAgent.mcp_prompts/mcp_get_prompt/mcp_resources/mcp_read_resource(app-facing; never injected automatically) - Live smoke tests (
tests/live/): a real fastmcp server over stdio (credential-free, runs in CI) plus env-gated Anthropic/OpenAI/Gemini turns — the first tests to touch real APIs - Agent event stream:
Agent.stream()/Agent.astream()expose the full tool-use loop as one ordered stream of typed events (AgentEvent:TurnStarted,RoundStarted,TextDelta,ToolStarted/ToolFinishedcorrelated bytool_call_id,CompactionStarted/CompactionFinished,RoundFinishedwith per-roundRoundUsage, terminalTurnFinishedwithTurnDiagnostics);chat/achatare now text-only projections of the same loop. Async tool calls emitToolFinishedlive in completion order (results return to the model in call order); subagent events are forwarded flat into the parent stream withparent_tool_call_idset; diagnostics persist even when the consumer abandons the generator - Usage limits in the agent loop:
Agent.with_usage_limits(UsageLimits(total_tokens_limit=..., tool_calls_limit=...))— crossing a limit emitsUsageLimitReached, grants the model one wrap-up round (final-round notice +tool_choice="none") and ends the turn withstopped_by="usage_limit"; no exception, state survives. Complements the pipelineTokenBudget(which governs what enters the context window) - Cross-provider usage estimation: when a provider reports no usage on the stream (all but Anthropic today),
RoundUsage.prompt_tokens/completion_tokensare estimated with the agent's tokenizer, so limits and accounting hold on every provider; provider-reported usage is never overridden.RoundUsagegainstool_callsandtotal_tokens;TurnDiagnosticsgainstotal_tokens/total_tool_calls - Generic
tool_choiceon every provider: passtool_choice="auto"|"any"|"none"or{"type": "tool", "name": ...}(Anthropic shape) tostream/invoke— mapped natively per provider (Anthropic passthrough, OpenAI/Grok/OpenRouter/Ollama/LiteLLMrequired/function form, Geminifunction_calling_config); the agent loop forcestool_choice="none"on the final round so the model must answer - Prompt caching (Anthropic):
AnthropicProvider(prompt_caching=True)sends GA top-levelcache_control={"type": "ephemeral"}(server picks the breakpoint); cache hits are visible via newUsage.cache_read_tokens/cache_creation_tokens, threaded intoRoundUsage/Agent.last_turn. TheAgentenables it by default when it builds an Anthropic provider — the tool loop re-sends a stable prefix every round, exactly the case caching pays for (opt out withllm=AnthropicProvider(prompt_caching=False)) - Server-side context management (Anthropic, beta):
Agent.with_context_management({"edits": [...]})passescontext_managementthrough each round; the provider auto-routes toclient.beta.messages.*with the right flags (context-management-2025-06-27forclear_*edits,compact-2026-01-12forcompact_20260112);compactionblocks are parsed (invoke and streaming) and round-trip verbatim viaMessage.raw_content/StreamChunk.raw_block/LLMResponse.raw_content - Emulated compaction (any provider):
Agent.with_compaction(trigger_tokens, keep_last=4, compact_fn=None)summarizes older loop messages into one[Conversation summary]message when the turn exceeds the trigger — default summarizer reusesTierCompactorwith the agent's own LLM; never severs a tool_use/tool_result pair and skips when the head wouldn't shrink - Subagents (MULTI_AGENT.md implemented):
Agent.as_tool(name, description, output_model=..., max_output_retries=...)wraps any agent as an orchestrator tool with a clean context (thetaskstring is the only channel in), condensed structured returns (schema instruction + Pydantic validation + one self-contained retry on invalid JSON), and a registration-time no-nesting guard; declarative layer viaAgent.with_subagents([SubagentDefinition(...)])+ a singletask(agent_name, task)meta-tool with a cache-stable discovery listing in the system prompt; parallel subagent spawns compose with concurrent tool execution inachat - Pre/post tool hooks:
Agent.with_hooks(pre_tool_use=[...], post_tool_use=[...])— pre-hooks can deny (reason is fed back to the model as anis_errortool result) or rewrite input, post-hooks can replace output; a raising pre-hook fails closed (call denied) - Observer callbacks:
Agent.with_callbacks([...])withAgentCallbackprotocol (on_round_start/end,on_tool_start/end/error), fire-and-forget via the sharedfire_callbacks;TracingAgentCallback+SpanKind.TOOLwire tool execution into observability - Per-round token accounting:
Agent.last_turn(TurnDiagnostics) records per-round provider usage, tool-schema tokens, tool-result tokens, andstopped_by(stop/max_rounds/max_tokens);Agent.with_budget(TokenBudget)attaches a budget to the context pipeline - Deferred tool loading:
AgentTool(defer_loading=True)keeps a tool's schema out of the prompt until the auto-registeredsearch_toolsmeta-tool (keyword/regex over names+descriptions) loads it — loaded tools stay loaded for the session input_examplesonAgentTool/ToolSchema, forwarded by the Anthropic provider (GA field)- Per-tool timeouts:
AgentTool(timeout=...)/Agent(tool_timeout=...), enforced withasyncio.wait_foron async tool callers (MCP, subagents); a timeout becomes anis_errortool result the model sees - MCP bridge (
[mcp]extra, fastmcp): consume external MCP servers as agent tools —MCPServerConfig(with config-string parser),FastMCPClientBridge, andMCPClientPoolfor multi-server management with lazy connection fromAgent(mcp_servers=[...]); expose an anchor agent as an MCP server viaFastMCPServerBridge; PEP 544MCPClient/MCPServerprotocols, tool adapters, and an error hierarchy underAstroContextError - Progressive summarization memory:
ProgressiveSummarizationMemorycascades older turns through summary tiers instead of evicting them, withTierCompactordoing LLM summarization plus durable fact extraction; includes the data models, aProgressiveSummarizationCallbacklifecycle protocol, andMemoryManagerintegration - Persistent storage backends: SQLite, PostgreSQL, and Redis implementations of the storage protocols (
[sqlite]/[postgres]/[redis]extras) for conversation and memory persistence beyond the in-memory and JSON-file references - Golden-set eval harness (
anchor.evaluation.golden):GoldenCaseJSONL loading,evaluate_retriever,assert_metric_floor— the CI-gate primitive for retrieval changes;RetrievalMetricsCalculatornow supports graded NDCG via{id: grade}relevance maps SqliteVecVectorStore([sqlite-vec]extra): real KNN inside SQLite via the vec0 virtual table (C cosine distance, declared dimensions,wherepre-filtering through rowid IN) — replaces the full-scan Python cosine path for local persistenceMarkdownHeaderChunker: structure-aware chunking — splits at headings, stamps each chunk with itsH1 > H2path in metadata and (by default) in content- Parsers:
CSVParser(header-context rows),JSONParser,DocxParser(stdlib zipfile+ElementTree, DTD-rejecting);MarkdownParsernow parses YAML frontmatter into metadata instead of discarding it - Per-page PDF provenance:
PDFParser.parse_pages+ page-aware ingestion stamps every chunk withdoc_pagefor citations - Contextual retrieval hook:
DocumentIngester(contextualize_fn=...)prepends caller-generated chunk context (Anthropic contextual-retrieval pattern), preserving the original inmetadata["original_content"] - Real CLI:
anchor index(ingest → chunks + optional dense vectors in one SQLite file) andanchor query(BM25 + optional dense, RRF-fused) — works fully offline; previously both were placeholders - Embeddings layer:
EmbeddingProviderprotocol (query/document asymmetry, native batching, async,dimensionsfor Matryoshka truncation) + providers behind extras —OpenAIEmbeddingProvider([openai]),VoyageEmbeddingProvider([voyage]),SentenceTransformerEmbeddingProvider([local-embeddings], BGE-M3 default for multilingual/PT) — andCallableEmbeddingProviderunifying the legacyembed_fnshapes - Metadata filtering:
where: dictparameter onVectorStore.search/AsyncVectorStore.search, pushed down in every backend (InMemory dict match, SQLitejson_extractin SQL, Postgres JSONB containment@>with a GIN index) and exposed onDenseRetriever.retrieve/AsyncDenseRetriever.aretrieve— unblocks user/tenant/type scoping AsyncDenseRetrieverstore-backed mode (vector_store=+context_store=) over theAsyncVectorStoreprotocol — pgvector (PostgresVectorStore) andAsyncSqliteVectorStoreare now reachable from a built-in retriever- pgvector integration test suite gated by
ANCHOR_TEST_POSTGRES_DSN(previously zero Postgres tests) - Skills: agentskills.io spec compliance — real YAML frontmatter (multi-line descriptions, quoting), spec fields
license,compatibility,metadata,allowed-tools, and name==directory validation;pyyamladded as a core dependency - Skills: level-3 progressive disclosure —
read_skill_filemeta-tool loadsreferences/lazily (path-traversal guarded, 50KB cap);run_skill_scriptexecutesscripts/on demand (opt-in viaAgent(allow_skill_scripts=True), 60s timeout); activation responses list bundled files - Skills:
always-skillinstructionsare now injected into the system prompt (previously dead text that never reached the model) - Skills: minimal eval harness (
run_skill_eval,SkillEvalCase/Result/Report) for with/without-skill A/B baselines with a callback judge SkillRegistry.all_skills(),always_skills(),always_instructions(), and amax_charscap onskill_discovery_prompt()(~1% of context budget in the agent)metadata["raw_score"]on all retriever and reranker results, preserving the unclamped/unnormalized score (raw cosine, raw BM25, raw reranker logits) so quality thresholds are possiblemin_scoreparameter onDenseRetrieverandSparseRetrieverto filter results below a raw-score threshold- SOTA 2026 research docs (
docs/research/2026-08-25-*) and phased upgrade plan (docs/plans/2026-08-25-sota-upgrade-plan.md) - Multi-provider LLM interface (
anchor.llm) with support for Anthropic, OpenAI, Gemini, Grok, Ollama, OpenRouter, and LiteLLM LLMProviderprotocol andBaseLLMProviderABC with built-in retry and timeout logiccreate_provider()factory with"provider/model"string format and automatic lazy loadingFallbackProviderfor automatic provider failover (fallback only before first stream chunk)- Provider error hierarchy:
ProviderError,RateLimitError,ServerError,LLMTimeoutError,AuthenticationError,ModelNotFoundError,ContentFilterError - Thread-safe provider registry with
threading.Lock - Shared
_openai_compatmodule for OpenAI/LiteLLM code deduplication - Anthropic streaming usage tracking (
input_tokens+output_tokens) - LLM Providers API reference and guide documentation
- Unit tests for
_math.py(cosine_similarity and clamp functions) MemoryRetrieverAdaptertests verifying Retriever protocol compliancePipelineExecutionErrorwrapping test with diagnostics verification- Golden path integration test mirroring README usage pattern
- Example:
examples/hybrid_rag.py-- hybrid RAG pipeline with dense retrieval - Example:
examples/custom_retriever.py-- custom Retriever protocol implementation - Example:
examples/budget_management.py-- token budget management and overflow handling - README sections for Priority System (1--10 scale) and Token Budgets
Changed¶
- Sync tools deliberately run without a timeout — a worker-thread "timeout" cannot cancel the call, only abandon a thread whose side effects keep running; the async path (
tool.timeout/Agent(tool_timeout=...)) is where timeouts are enforceable.run_skill_scriptdrops its local head-only 10k-char cut in favor of the loop's head+tail cap (per-toolmax_result_tokens=2500) - fastmcp pinned to
>=3.4,<4(locked 3.4.7); the 4.x stateless-protocol (MCP 2026-07-28) migration is tracked for when it leaves beta anchor._text.strip_markdown_fences: single shared fence stripper (replaces three inline copies; fixes the compactor variant that dropped the last line even without a closing fence)- Provider
call_kwargsconstruction deduplicated (was copy-pasted 12×): one_build_call_kwargsin the Anthropic provider, one sharedbuild_call_kwargsfor the OpenAI-compatible family (covers Grok/OpenRouter/Ollama/LiteLLM) AnthropicFormattercaching now emits valid wire format:cache_controlsits on a content block of the context message (applied after role merging), not as a top-level message key_make_subagent_tooluses the@tooldecorator (full Pydantic input validation) instead of a hand-built schema; meta-tool namestask/search_toolsare collision-checked at registration/creation timeAgent.last_turnresets toNoneat turn start — an abandoned generator no longer shows the previous turn's diagnosticsAgent.chat/achatshare one loop body (turn preparation, round assembly, chunk ingestion, accounting) instead of ~70 duplicated lines;achatnow executes independent tool calls concurrently (asyncio.gather, order-preserving, all results in one message)- Tool errors are forwarded to the model with diagnostics (
Error: tool 'x' failed: RuntimeError: boom) andis_error=True— previously a bare"Error: tool 'name' failed."; the Anthropic provider now setsis_errorontool_resultblocks (previously dropped) - Round-limit signalling: one round before
max_roundsthe agent injects a final-round notice so the model wraps up instead of being cut off; tool calls made on the final round are not executed (their results could never reach the model); the outcome is visible aslast_turn.stopped_by == "max_rounds"instead of a silent stop Agentdefault tokenizer: tiktoken-backed when installed (whitespace-word fallback otherwise) — token accounting and the skill-listing cap now use real counts;Agent(tokenizer=...)accepts anyTokenizer- BM25 backend: bm25s (numpy sparse scoring, up to 500x faster, Lucene-variant correctness) with Snowball stemming + stopwords via
SparseRetriever(language=...)— Portuguese finally stems (correndomatchescorrer); rank_bm25 remains the fallback and the path for customtokenize_fn; thebm25extra now shipsbm25s + PyStemmer - Chunking defaults:
RecursiveCharacterChunker512/50 → 384/0 per Chroma's chunking evaluation (recursive at 200-400 tokens, zero overlap, within ~2 recall points of semantic chunking) - Chunker sizing loops are O(n) (per-word token counts) instead of re-encoding the growing chunk per word (O(n²)); exact for whitespace tokenizers, approximate for BPE
ParentChildChunkerstores parent text once on the chunker (content-hash ids, globally unique across documents — the oldparent-{idx}collided between docs) instead of duplicating the full parent into every child's metadata;ParentExpandertakes aparent_lookupLLMRAGEvaluator: unconfigured metric dimensions are nowNone(not evaluated) instead of a silent 0.0; constructing with zero callbacks raises- Skills:
Skill.activationdefault flipped from"always"to"on_demand"(matches the loader default and progressive-disclosure semantics); canonical frontmatter location for activation is nowmetadata: {activation: ...}(top-level key still accepted) - Skills: the discovery listing is static (no per-round
[active]mutation) and joined to the system prompt with newlines, computed once per turn — prompt-cache friendly; the agent'sAnthropicFormatternow has caching enabled - Skills: tool-name collisions raise at registration time (
SkillRegistry.registerandAgent.with_skill), never mid-conversation - Reranker
rerank()/arerank()signatures:top_k: int = 10→top_k: int | None = None; an explicittop_know always overrides the constructor value,Nonefalls back to it (applies to all rerankers, theReranker/AsyncRerankerprotocols, andreranker_step/async_reranker_step) AsyncCohereRerankercallback now returns(index, score)tuples, matching the syncCohereRerankershape, and applies scores to resultsHybridRetrieverandAsyncHybridRetrievernow delegate fusion to the canonicalrrf_fuse(single RRF implementation);rrf_fusegained aretrieval_methodlabel parameterAsyncHybridRetrieverraisesRetrieverErrorwhen all sub-retrievers fail, matching the sync behavior (previously returned[])- All cosine-similarity call sites (
SemanticChunker, late interaction, cross-modal,EmbeddingClassifier) now use the strictanchor._math.cosine_similarity— dimension mismatches raise instead of silently truncating DenseRetriever.indexembeds documents in one batch call (was one embedding call per item)- Postgres:
ensure_tablesnow creates the pgvector HNSW index (works on empty tables, unlike the previously suggested IVFFlat) and a JSONB GIN index;embedding_dimis a required parameter RoleandStopReasonenums changed from(str, Enum)toStrEnumfor correct string formatting- Repo workflow:
docs/superpowers/retired; specs/plans live indocs/plans/, research indocs/research/
Fixed¶
async deftool functions are awaited (@toolandAgentTool.fn): the loop used to stringify the coroutine ('coroutine' object has no attribute 'encode') — the async path awaits it with the tool'stimeout,chat()runs it in a private event loop (the thread's current loop is left alone), andchat()driven from inside a running loop raisesTypeErrorto the caller — a developer mistake, not a tool failure for the model- A consolidator's
DELETEwas silently ignored — the protocol said the entry "may be None" forDELETE(no target), and the pipeline's consolidation step only ever appliedADD/UPDATE. Theentryslot now names the target per operation andDELETEis applied as a soft delete (expires_at = now), hidden fromsearch/list_allin every backend (the expiry contract is now tested on Redis and Postgres too) and taken out of the knowledge graph byGraphIndexingEntryStore— an expired entry never evidences the graph SimilarityConsolidatorembedding cache is an LRU keyed by text (functools.lru_cache), not a wipe-when-full dict keyed by entry id — an updated entry no longer matches against its old embedding, and a store past the cache size no longer re-embeds everything on every pass- Knowledge graph, review ritual of the #4 front (session 11) — 15 confirmed findings, all fixed:
anchor index --vault X --graphcrashed on a vault mismatch (chunks were only re-stamped with the namespace); a naivevalid_from/valid_tomade every read raiseTypeError(now rejected at construction: every graph datetime must be timezone-aware); re-indexing under another--namespaceraised and left the graph split (the latest link now moves the item, as theContextStoredoes); a frontmatter tag#produced an empty node id and aborted the run; an empty seed name raised out ofquery/related_items/GraphRetrieverinstead of being ignored; edge endpoints inherited no evidence, soRetrievalScope()hid nodes an unscoped read showed andWikilinkExtractoralone left the source note invisible (an edge's evidence now evidences both endpoints, and a node without evidence lives at the root namespace —RetrievalScope()is the identity); reinforcing a live edge droppedvalid_from/valid_to(now merged; anadd_edgecarryinginvalidated_atis refused);MemoryManager.clear()missed expired entries and the garbage collector / consolidation step bypassed the graph hook (GraphIndexingEntryStorenow wraps the store for every writer);KnowledgeGraphaccepted async stores silently; SQLiteINSERT OR REPLACEreordered nodes and a placeholder label stuck forever; thecommunities()cache never expired withas_of=None;GraphRetrievernormalized scores against a dropped (memory) id; aliases were not resolved bypath/explain/backlinks/node; scoped walks re-scannedgraph_itemsonce per visited node (10× slower) —neighbors/path/explain/related_itemsnow load the visible subgraph once and walk it with the shared algorithms, andretrieve()loads it once for both the mention matcher and the walk;PostgresGraphStore.upsert_noderaced (nowFOR UPDATEin a transaction) and bumpsgraph_metalike SQLite;IndexStatscounted extractor emissions, not distinct nodes/edges;graph_retrieval_stepitems carryid=entry.id normalize_keyfolds separators: whitespace,-and_runs become one_, soauth-service,Auth ServiceandAUTH_SERVICEare one node (other punctuation is kept —C++,.NETstay distinct)- Pre-vault migrations rebuilt from the ritual xhigh of roadmap #3 (sessão 10): SQLite/sqlite-vec legacy tables kept a bare-id key, so
INSERT OR REPLACEfrom one vault silently deleted another vault's row (andvec_itemsrejected a second vault) —ensure_tablesnow rebuilds the(vault, id)key in one transaction, rowids preserved; Postgres legacy tables lacked the unique indexON CONFLICT (vault, id)needs (42P10 on every write) — the migration probes the catalog, recreates the composite PK and putsCOLLATE "C"onnamespace(prefix ranges under libcen_UScollations dropped descendants on include and leaked them on exclude); the vec0 rebuild is transactional with a TEMP staging table - Same
where, same answer on every backend: SQL$ne/$ninno longer drop rows missing the key (memory and Postgres kept them); range operators compare like with like — the Postgres::numericcast crashed every search on a vault holding one non-numeric value, SQLite matched strings on$gt(TEXT sorts above INTEGER) - Scope reaches the pipeline: the turn's
ContextPipeline.build()ran before the published scope existed, sowith_scope()and a parent's scope never applied toretriever_stepretrieval (sync, async, subagent) — the build now runs under the same published scope as tool calls DenseRetriever/AsyncDenseRetrieverrefuse a vector store and a context store mounted on different vaults (they indexed vectors into one and content into the other, silently)anchor queryis back onHybridRetriever(a dense failure aborted the command instead of degrading to BM25 with a warning);anchor migratereads the vec0 dimension from the index instead of a regex, and reports a missingsqlite-vecinstead of a tracebackSqliteConnectionManager.get_async_connectioncloses a losing first-call connection when its setup fails (database is lockedwhile the winner switches to WAL) and retries — the leaked non-daemon worker thread used to hang interpreter shutdown, i.e. the whole test suitecreate_provider()deadlocked on first use of any provider: the registry_LOCKwas a non-reentrantthreading.Lockheld during the lazy provider import, and every provider module self-registers on import by re-acquiring the same lock on the same thread — in a fresh process,Agent(model="anthropic/claude-...")(or anycreate_provider()call) hung forever. Shipped in 0.1.1; the mocked suite never saw it because provider tests import the classes directly first. Fixed withRLockplus a regression test exercising the real lazy-import path under a joined-with-timeout thread- MCP stdio connections with arguments actually work: the bridge passed a concatenated
"command arg arg"string that fastmcp's transport inference rejects — found by the first live smoke test (the mocked suite patchedClientand never saw it); the bridge now builds an explicitStdioTransport - Gemini streaming tool calls now work: the stream parser emits generated call ids, sequential indices for parallel calls, JSON argument fragments (was
str(dict)), and aTOOL_USEstop reason override mirroring the non-stream path — previously tools requested via Gemini streaming silently never executed - Agent without memory never sent the user's message: the formatter emits no conversation when no memory is attached, so
chat("...")produced a system-only request (a real API call would 400); the turn now guarantees the user message is present - Reranker
top_ksentinel bug: passingtop_k=10explicitly was indistinguishable from the default and silently discarded in all five rerankers; the pipelinereranker_stepdefault hit this on every run RecursiveCharacterChunkerapplied overlap at every recursion level, duplicating overlap text in nested sub-chunks; overlap is now applied exactly once over the final chunk listSqliteVectorStoreunpacked stored embeddings using the query vector's dimension — a query/stored dimension mismatch silently mis-unpacked or crashed withstruct.error; the dimension now derives from the stored blob and mismatches raise a clearValueError- 34 pre-existing test failures caused by missing optional dependencies (tiktoken, rank-bm25)
FallbackProvider.astreammid-stream fallback semantics (yields now outside try/except)test_consolidator.py: eliminated shared mutable state (_orthogonal_indexdict) by converting to factory function pattern (make_orthogonal_embed())test_graph_memory.py: updatedlink_memoryunknown entity test to expectKeyErrorinstead ofValueError- README: fixed retrieval example with runnable
embed_fnandContextItemcreation - README: updated test count from 961 to 1088
- NDCG graded a short result set against a shrunken ideal: the ideal ranking was truncated at the number of items returned instead of at k — a broken retriever returning only its 2 relevant hits scored a perfect NDCG@10, so the golden-set CI gate passed on real regressions that shrank the result set (both the binary and graded variants; shipped in 0.1.x)
SlidingWindowMemoryinvokedon_evictwhile holding its non-reentrant lock: a callback that read the memory back (e.g.total_tokens) self-deadlocked, and slow eviction work (LLM summarization in progressive memory) serialized every thread on the window; the callback now fires after the lock is released (shipped in 0.1.x)
Removed¶
AsyncSqliteGraphStore(never shipped in a release): ato_threadwrapper with no consumer —KnowledgeGraphis synchronous and refuses async stores;PostgresGraphStorestays the async backendanchor index --language/-l: it was displayed but never persisted (BM25 is built at query time —anchor query -lis where the language matters)- Late-interaction retriever (
LateInteractionRetriever/LateInteractionScorer/MaxSimScorer,TokenLevelEncoderprotocol): required per-token document embeddings recomputed per query — never viable as shipped, no known users; a ColBERT-style retriever can return behind an extra when demanded TokenBudgetExceededError(never raised anywhere; usage limits stop gracefully instead of raising) and the orphananchor.models.streamingmodule (StreamDelta/StreamUsage/StreamResult— superseded byAgentEvent)ScoreReranker(dead duplicate ofCrossEncoderRerankerimplementing the wrong protocol)
[0.1.1] - 2026-03-13¶
First PyPI release. Same feature set as 0.1.0 — the delta is packaging, branding, and documentation (verified against the published sdist).
Changed¶
- Project renamed from
astro-contextto anchor; the import is nowanchorand the PyPI package name isastro-anchor - Comprehensive documentation overhaul (MkDocs Material, GitHub Pages deploy workflow), modernized README, Astro brand identity and logos
Fixed¶
- mypy arg-type error for the
Skillactivation literal; deterministic_fake_embedand otel import-blocking in tests
[0.1.0] - 2026-02-20¶
Added¶
- Core context pipeline with sync/async support (
ContextPipeline) - Token-aware sliding window memory (
SlidingWindowMemory) - Summary buffer memory with progressive compaction (
SummaryBufferMemory) - Memory manager facade unifying conversation and persistent memory (
MemoryManager) - Hybrid RAG retrieval: dense, sparse (BM25), and hybrid (RRF) retrievers
- Multi-signal memory retrieval with recency/relevance/importance scoring (
ScoredMemoryRetriever) - Provider-agnostic formatting: Anthropic, OpenAI, and generic text formatters
- Anthropic multi-block system formatting with prompt caching support
- Protocol-based extensibility (PEP 544) for all extension points
- Token budget management with per-source allocations and overflow tracking
- Pluggable eviction policies: FIFO, importance-based, and paired (user+assistant)
- Memory decay: Ebbinghaus forgetting curve and linear decay
- Recency scoring: exponential and linear strategies
- Memory consolidation with content-hash dedup and cosine-similarity merging
- Simple graph memory with BFS traversal for entity-relationship tracking
- Memory garbage collection with two-phase expired+decayed pruning
- Memory callback protocol for lifecycle observability
- Pipeline query enrichment with memory context
- Auto-promotion of evicted turns to long-term memory
- In-memory reference implementations for all storage protocols
- JSON file-backed persistent memory store
- CLI with index and query commands (via typer+rich)
- 961 tests with 94% coverage