memleaf 0.2.43__tar.gz → 0.2.45__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memleaf-0.2.43 → memleaf-0.2.45}/CHANGELOG.md +16 -0
- {memleaf-0.2.43/src/memleaf.egg-info → memleaf-0.2.45}/PKG-INFO +4 -2
- {memleaf-0.2.43 → memleaf-0.2.45}/README.en.md +3 -1
- {memleaf-0.2.43 → memleaf-0.2.45}/README.md +3 -1
- memleaf-0.2.45/docs/extraction-latency.md +50 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/pyproject.toml +1 -1
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/cli.py +13 -1
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/config.py +55 -0
- memleaf-0.2.45/src/memleaf/extraction_budget.py +210 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/extraction_capability.py +14 -15
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/extraction_work_state.py +9 -66
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/llm/base.py +5 -2
- memleaf-0.2.45/src/memleaf/llm/openai_compatible.py +312 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/llm/router.py +18 -11
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/llm/thinking.py +19 -8
- memleaf-0.2.45/src/memleaf/model_capabilities.py +189 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/model_discovery.py +31 -16
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/model_execution.py +60 -1
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/process_common.py +2 -2
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/process_jobs.py +13 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/processing.py +15 -26
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/single_pass_memory_planner.py +10 -1
- memleaf-0.2.45/src/memleaf/single_pass_plan.py +1063 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/validation.py +115 -0
- {memleaf-0.2.43 → memleaf-0.2.45/src/memleaf.egg-info}/PKG-INFO +4 -2
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf.egg-info/SOURCES.txt +2 -0
- memleaf-0.2.43/src/memleaf/extraction_budget.py +0 -280
- memleaf-0.2.43/src/memleaf/llm/openai_compatible.py +0 -165
- memleaf-0.2.43/src/memleaf/single_pass_plan.py +0 -537
- {memleaf-0.2.43 → memleaf-0.2.45}/LICENSE +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/MANIFEST.in +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/capture-budget-design.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/config-migrations.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/core-refactor.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/evidence-retention.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/gate-evidence-boundary.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/general-processing.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/hermes-mcp-runtime.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/processing-quality-acceptance.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/docs/v0.2.26-processing-status.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/examples/README.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/examples/basic_usage.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/install.ps1 +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/install.sh +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/setup.cfg +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/admission.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/batch_review.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/create_coordinator.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/evidence_budget.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/evidence_policy.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/evidence_structure.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/evidence_syntax.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/_provider.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/_shared.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/hermes_runtime.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/index.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/inspection.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/memory_commit.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/memory_planner.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/models.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/native_registration.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/parallel_model.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/planning_context.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/process_journal.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/process_owner.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/prompts.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/provenance.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/recording_policy.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/retention.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/service.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/source_policy.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/state_layout.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/summary_batch.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/target_reconciliation.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/turn_audit.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/turn_plan.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/update_coordinator.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/update_review.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.43 → memleaf-0.2.45}/src/memleaf.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to memleaf are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.2.45 — 2026-09-12
|
|
6
|
+
|
|
7
|
+
- Repair the `b3-single-pass-v1` automatic extraction contract so model output is constrained by the same CREATE / UPDATE / NO_CHANGE / DEFERRED fields, evidence shapes, no-memory/defer reasons, target rules and lookup-completeness checks that Core validates. Pure ASCII decision-case differences are normalized locally; unknown decisions and missing semantic fields still fail closed.
|
|
8
|
+
- Replace the generic second full-prompt retry with one B3-specific structural repair. The repair receives only the previous untrusted B3 object, the compact protocol and allowlisted structural diagnostics; Core rejects candidate count/order changes, evidence/target/scope/memory drift, or any edit outside the explicitly repairable protocol redundancy. Automatic extraction remains limited to at most two actual model requests and never fabricates a terminal disposition.
|
|
9
|
+
- Separate credential/profile name (`provider`), wire protocol (`protocol`) and capability identity (`provider_family`). DeepSeek capabilities are resolved only from explicit configuration, exact legacy provider IDs, or exact official hostnames; custom aliases, model-name substrings and lookalike domains no longer grant provider-specific JSON/thinking behavior or override an explicit protocol. Unknown OpenAI-compatible services fail closed for single-pass capability.
|
|
10
|
+
- Make DeepSeek single-pass primary and repair requests send explicit `thinking: {type: disabled}` and `response_format: {type: json_object}` from the resolved capability profile, with independently configurable bounded output ceilings. Finish-reason truncation is rejected before commit, repair output is never truncated to fit a budget, and transport success, parser rejection and commit acceptance remain separate metrics.
|
|
11
|
+
- Refine thinking telemetry to distinguish requested, applied and observed state, preserve missing usage as unknown, and report observed reasoning when provider usage or response content proves it. The HTTP transport base no longer grants `single_pass_safe` by inheritance; B3 protocol compatibility and bounded one-request transport semantics are evaluated separately.
|
|
12
|
+
- Validation for this release used the exact v0.2.44 source artifact at `c13c6dedacfdf6206fd8ebdaa04c4d84fdc0b919`: 88 focused tests passed, including temporary-Vault atomicity/idempotency/revision-conflict boundaries, provider request serialization, B3 repair fidelity, request-count limits and the advisory ten-second latency rule. No real provider call or production-session replay was used, so real-model first-pass success rate, repair success rate, reasoning reduction and latency improvement remain unmeasured.
|
|
13
|
+
|
|
14
|
+
## 0.2.44 — 2026-09-12
|
|
15
|
+
|
|
16
|
+
- Correct the extraction latency policy: ten seconds is an advisory performance target, not a cancellation or commit deadline. Remove the fixed six-second primary cap and eight-second shared window; single-pass HTTP requests now honor `llm.request_timeout`, including the one bounded format repair.
|
|
17
|
+
- Accept valid, Core-checked results after the target and recover frozen background plans without expiring them by old wall-clock timestamps. Keep durable request counts, damaged-state handling, revision/ownership validation and normal transport errors intact.
|
|
18
|
+
- Add structural per-turn extraction timing to synchronous results and background job result/error summaries. Track planning/commit time, slow attempts and failed attempts separately; a fast failure is not counted as successful target achievement. No prompt, response, credential or memory body is added to these metrics.
|
|
19
|
+
- Keep tests local-only and leave the hosted build/smoke CI policy unchanged. Real-provider latency and quality acceptance remain separate; removing premature cancellation is not a measured ten-second performance claim.
|
|
20
|
+
|
|
5
21
|
## 0.2.43 — 2026-09-12
|
|
6
22
|
|
|
7
23
|
- Keep regression tests, live-acceptance helpers, and implementation/release plans in local checkouts rather than the Git tree or source distribution.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memleaf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.45
|
|
4
4
|
Summary: A local-first Markdown memory core for AI agents
|
|
5
5
|
Author: memleaf contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,7 +23,7 @@ Dynamic: license-file
|
|
|
23
23
|
|
|
24
24
|
[English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
25
25
|
|
|
26
|
-
> **版本:0.2.
|
|
26
|
+
> **版本:0.2.45。**
|
|
27
27
|
> 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
|
|
28
28
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
29
29
|
|
|
@@ -85,6 +85,8 @@ Hermes 使用原生 MemoryProvider 维护生命周期,并通过 MCP 获取 Sco
|
|
|
85
85
|
|
|
86
86
|
## 记忆提炼规则
|
|
87
87
|
|
|
88
|
+
**10 秒是自动提炼的性能目标,不是失败阈值。** HTTP 请求遵循 `llm.request_timeout`(默认 120 秒);正常返回且通过 Core 校验的结果不会仅因超过 10 秒而丢弃。模型单次请求、有限修复和逐轮提交保留,耗时与是否完成分别统计。详见 [提炼耗时与超时](docs/extraction-latency.md)。
|
|
89
|
+
|
|
88
90
|
memleaf 不把每句话都保存为记忆。处理一轮完整的 user + assistant 可见文本时,模型先判断是否存在明确的未来复用价值:
|
|
89
91
|
|
|
90
92
|
- `CREATE`:没有相关现存记忆,创建一条原子、可独立理解的记忆;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **Version: 0.2.
|
|
7
|
+
> **Version: 0.2.45.**
|
|
8
8
|
> Automatic extraction now uses only the current turn's visible user input and final assistant reply. Raw tool output, attachments, web/file/terminal payloads and legacy tool-evidence bodies are not new source evidence; existing or retrieved memory remains comparison context rather than source authority. Background processing is persisted as a local job and can be checked through the read-only `process_status` MCP tool; failed work remains retryable and fail closed. The release also tightens source/date grounding, target reconciliation, duplicate/no-op handling and semantic review before writes. Markdown remains the sole source of truth with no SQLite runtime dependency. Acceptance covers deterministic regression suites and synthetic inputs; it does not claim real-mail or customer-business acceptance.
|
|
9
9
|
> **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
|
|
10
10
|
|
|
@@ -64,6 +64,8 @@ Hermes uses a native MemoryProvider for lifecycle handling and obtains the Scope
|
|
|
64
64
|
|
|
65
65
|
## Memory admission and maintenance
|
|
66
66
|
|
|
67
|
+
**Ten seconds is an extraction performance target, not a failure deadline.** HTTP requests honor `llm.request_timeout` (120 seconds by default). A valid, Core-checked response is not discarded just because processing exceeds ten seconds. Single-pass planning, bounded repair and per-turn commits remain; completion and latency are measured separately. See [extraction latency and timeouts](docs/extraction-latency.md).
|
|
68
|
+
|
|
67
69
|
memleaf does not save every sentence. When a complete visible user + assistant turn is processed, the model first evaluates whether it has concrete future reuse value:
|
|
68
70
|
|
|
69
71
|
- `CREATE`: no related active memory exists, so create one atomic, self-contained memory;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
|
|
6
6
|
|
|
7
|
-
> **版本:0.2.
|
|
7
|
+
> **版本:0.2.45。**
|
|
8
8
|
> 记忆只从用户与 Agent 的可见对话提炼。Agent 已在回复中整理的事实、项目进展和明确待办可作为来源;邮件、附件、网页和终端等工具原文不进入记忆提炼。模型负责保留原话中的不确定性,现有记忆仅用于比较、去重和更新。Markdown 仍是唯一事实源。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
@@ -66,6 +66,8 @@ Hermes 使用原生 MemoryProvider 维护生命周期,并通过 MCP 获取 Sco
|
|
|
66
66
|
|
|
67
67
|
## 记忆提炼规则
|
|
68
68
|
|
|
69
|
+
**10 秒是自动提炼的性能目标,不是失败阈值。** HTTP 请求遵循 `llm.request_timeout`(默认 120 秒);正常返回且通过 Core 校验的结果不会仅因超过 10 秒而丢弃。模型单次请求、有限修复和逐轮提交保留,耗时与是否完成分别统计。详见 [提炼耗时与超时](docs/extraction-latency.md)。
|
|
70
|
+
|
|
69
71
|
memleaf 不把每句话都保存为记忆。处理一轮完整的 user + assistant 可见文本时,模型先判断是否存在明确的未来复用价值:
|
|
70
72
|
|
|
71
73
|
- `CREATE`:没有相关现存记忆,创建一条原子、可独立理解的记忆;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 提炼耗时与请求超时
|
|
2
|
+
|
|
3
|
+
## 性能目标与失败条件分开
|
|
4
|
+
|
|
5
|
+
日常自动提炼以 10 秒内完成为性能目标,但到第 10 秒不会主动报失败,也不会丢弃已正常返回并通过 Core 校验的结果。超过目标说明本次处理偏慢,不说明记忆无效或没有需要保存的信息。
|
|
6
|
+
|
|
7
|
+
v0.2.42 / v0.2.43 的固定 6 秒首请求、8 秒准备加模型窗口、10 秒提交前拒绝属于错误的目标实现,当前修正移除了这些时间闸门。修正本身不代表真实模型的 P50/P95 已达标。
|
|
8
|
+
|
|
9
|
+
HTTP 请求使用已有配置:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
llm:
|
|
13
|
+
request_timeout: 120
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
这个参数仍按现有规则校验(1~240 秒,可用小数),并传到 HTTP transport。single-pass 不再静默把它缩短为 6 秒。第二次结构修复请求使用相同的配置,不再只分到原来 8 秒窗口的剩余时间。请求超时是 transport 的等待限制,不是整个后台任务的端到端完成保证;host/Python callback 仍由其宿主负责请求超时与取消。
|
|
17
|
+
|
|
18
|
+
真正的请求超时、网络故障、无效模型输出、目标版本冲突、写入失败仍然是失败或未完成,不能伪装为 `NO_CHANGE`。相应 inbox、journal 和恢复锚点保留,是否提交仍由 Core 校验决定。
|
|
19
|
+
|
|
20
|
+
## 保留的调用与提交约束
|
|
21
|
+
|
|
22
|
+
普通 B3 自动提炼仍以一次结构化模型调用为正常路径,格式/结构修复最多一次。固定安全路由保留实际 outbound 请求计数和持久化预占,不增加隐藏 host-to-API fallback。并未恢复 Gate → Summary → Review 多阶段主路径。
|
|
23
|
+
|
|
24
|
+
每轮仍先读取持久状态、规划、校验、提交,再处理下一轮;一轮超过目标不会占用下一轮的“时间许可”。compaction 仍不在普通提炼的关键路径上。
|
|
25
|
+
|
|
26
|
+
旧 `extraction_request_budget.json` 中的 `started_at_epoch` 可以兼容读取,但不再作为请求或提交的过期时间。已消耗的 `requests` 不清零,损坏的计数账本也不会被当作空文件重建。FrozenTurn 的恢复不需要重新申请模型时间窗口;现有内容摘要、版本、所有权和写入校验保持不变。
|
|
27
|
+
|
|
28
|
+
这不是一个自动重放所有历史失败任务的迁移。旧 inbox 不会被清空;需要通过正常处理/重试入口继续处理,不能删除正确性状态来强行重新获取请求额度。
|
|
29
|
+
|
|
30
|
+
## 只记录结构化耗时
|
|
31
|
+
|
|
32
|
+
处理结果新增 `extraction_metrics`,后台 `process_status` 的 result/error 和尝试汇总保留同样的数字字段:
|
|
33
|
+
|
|
34
|
+
| 字段 | 含义 |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `target_duration_ms` | 固定观测目标 10000 毫秒,不参与失败判断 |
|
|
37
|
+
| `turn_count` | 本次测量的 turn 尝试数量 |
|
|
38
|
+
| `failed_turn_count` | 抛出处理异常的 turn 尝试数量 |
|
|
39
|
+
| `over_target_turn_count` | 耗时超过目标的 turn 尝试数量,可能成功也可能失败 |
|
|
40
|
+
| `successful_within_target_count` | 无处理异常且在目标内结算的尝试数量 |
|
|
41
|
+
| `total_duration_ms` | 被测 turn 尝试的耗时之和 |
|
|
42
|
+
| `max_turn_duration_ms` | 最慢一次 turn 尝试耗时 |
|
|
43
|
+
| `planning_duration_ms` | 读取状态、准备上下文、模型调用和本地计划校验耗时 |
|
|
44
|
+
| `commit_duration_ms` | 从进入提交阶段到提交和预算清理返回的耗时 |
|
|
45
|
+
|
|
46
|
+
每轮计时从选中该 turn 后、读取其处理状态前开始。它不包括排队、worker 启动及 `process()` 开头的整批 inbox claim 扫描;不能把该指标冒充用户可见端到端耗时。跨 worker 重启会产生新的尝试,旧进程的 wall-clock 起点不再限制新进程执行。
|
|
47
|
+
|
|
48
|
+
“无处理异常”包括合法 `NO_CHANGE` 和正常返回的部分处理;语义完整性仍由既有 `coverage_status`、deferred 数量、`memories_written` 和任务状态判断。因此耗时统计不替代提炼质量验收。快速失败不计入 `successful_within_target_count`。
|
|
49
|
+
|
|
50
|
+
`model_metrics` 继续分别提供模型请求耗时、调用/修复次数和输入输出长度等信息。以上结构化统计不新增正文、prompt、response 或凭证存储。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.45"
|
|
4
4
|
|
|
5
5
|
from .config import DEFAULT_CONFIG, default_config, load_config, save_config
|
|
6
6
|
from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
|
|
@@ -22,6 +22,7 @@ from .adapters.base import (
|
|
|
22
22
|
from .adapters.hermes import HermesAdapter
|
|
23
23
|
from .credentials import credential_text
|
|
24
24
|
from .config import load_config
|
|
25
|
+
from .model_capabilities import resolve_provider_capabilities
|
|
25
26
|
from .model_discovery import ModelCandidate, discover_models, manual_candidate, write_model_config
|
|
26
27
|
from .vault import Vault
|
|
27
28
|
|
|
@@ -531,6 +532,11 @@ def _existing_memleaf_route(path: Path) -> ModelCandidate | None:
|
|
|
531
532
|
if key is None:
|
|
532
533
|
return None
|
|
533
534
|
try:
|
|
535
|
+
capabilities = resolve_provider_capabilities(
|
|
536
|
+
provider=llm.get("provider"),
|
|
537
|
+
provider_family=llm.get("provider_family"),
|
|
538
|
+
base_url=llm.get("base_url"),
|
|
539
|
+
)
|
|
534
540
|
return ModelCandidate(
|
|
535
541
|
source="memleaf",
|
|
536
542
|
provider=str(llm.get("provider", "")),
|
|
@@ -540,6 +546,8 @@ def _existing_memleaf_route(path: Path) -> ModelCandidate | None:
|
|
|
540
546
|
api_key=key,
|
|
541
547
|
context_window=int(llm.get("context_window", 200000)),
|
|
542
548
|
source_detail="existing memleaf route",
|
|
549
|
+
provider_family=capabilities.provider_family,
|
|
550
|
+
provider_family_source=capabilities.source,
|
|
543
551
|
)
|
|
544
552
|
except (TypeError, ValueError):
|
|
545
553
|
return None
|
|
@@ -547,13 +555,17 @@ def _existing_memleaf_route(path: Path) -> ModelCandidate | None:
|
|
|
547
555
|
|
|
548
556
|
def _prompt_for_model() -> ModelCandidate:
|
|
549
557
|
print("memleaf: no complete callable chat model was found; configure one for processing.", file=sys.stderr)
|
|
550
|
-
provider = input("Provider
|
|
558
|
+
provider = input("Provider / credential profile name: ").strip()
|
|
559
|
+
provider_family = input(
|
|
560
|
+
"Provider family (openai/deepseek/anthropic/gemini/generic, blank=auto): "
|
|
561
|
+
).strip()
|
|
551
562
|
protocol = input("Protocol (openai/claude/gemini): ").strip()
|
|
552
563
|
base_url = input("Base URL: ").strip()
|
|
553
564
|
model = input("Model: ").strip()
|
|
554
565
|
api_key = getpass.getpass("API key (input hidden): ")
|
|
555
566
|
return manual_candidate(
|
|
556
567
|
provider=provider,
|
|
568
|
+
provider_family=provider_family,
|
|
557
569
|
protocol=protocol,
|
|
558
570
|
base_url=base_url,
|
|
559
571
|
model=model,
|
|
@@ -9,6 +9,9 @@ from typing import Any, Mapping
|
|
|
9
9
|
|
|
10
10
|
from .frontmatter import FrontmatterError, dump_yaml, load_yaml
|
|
11
11
|
from .locking import atomic_write_text
|
|
12
|
+
from .model_capabilities import (
|
|
13
|
+
legacy_protocol_for_provider, normalize_protocol, normalize_provider_family,
|
|
14
|
+
)
|
|
12
15
|
from .native_index import NativeConfigError, validate_native_sources
|
|
13
16
|
from .scope_state import ScopeError, validate_scope_registry
|
|
14
17
|
|
|
@@ -19,6 +22,10 @@ MAX_REQUEST_TIMEOUT = 240
|
|
|
19
22
|
DEFAULT_MODEL_CONCURRENCY = 3
|
|
20
23
|
MIN_MODEL_CONCURRENCY = 1
|
|
21
24
|
MAX_MODEL_CONCURRENCY = 8
|
|
25
|
+
DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS = 8192
|
|
26
|
+
DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS = 8192
|
|
27
|
+
MIN_SINGLE_PASS_MAX_TOKENS = 2048
|
|
28
|
+
MAX_SINGLE_PASS_MAX_TOKENS = 32768
|
|
22
29
|
THINKING_PURPOSES = ("gate", "summarize", "compact", "single_pass")
|
|
23
30
|
THINKING_MODES = frozenset({"default", "disabled", "low", "high", "max"})
|
|
24
31
|
DEFAULT_THINKING = {purpose: "low" for purpose in THINKING_PURPOSES}
|
|
@@ -40,6 +47,16 @@ def _normalize_request_timeout(value: Any) -> int | float:
|
|
|
40
47
|
return int(parsed) if parsed.is_integer() else parsed
|
|
41
48
|
|
|
42
49
|
|
|
50
|
+
def _normalize_single_pass_token_limit(value: Any, *, field: str, default: int) -> int:
|
|
51
|
+
if value is None or value == "":
|
|
52
|
+
return default
|
|
53
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
54
|
+
raise ValueError(f"invalid memleaf llm.{field}")
|
|
55
|
+
if not MIN_SINGLE_PASS_MAX_TOKENS <= value <= MAX_SINGLE_PASS_MAX_TOKENS:
|
|
56
|
+
raise ValueError(f"invalid memleaf llm.{field}")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def _normalize_model_concurrency(value: Any) -> int:
|
|
44
61
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
45
62
|
raise ValueError("invalid memleaf process.model_concurrency")
|
|
@@ -89,6 +106,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
89
106
|
"llm": {
|
|
90
107
|
"mode": "auto",
|
|
91
108
|
"provider": "",
|
|
109
|
+
"provider_family": "",
|
|
92
110
|
"protocol": "openai",
|
|
93
111
|
"base_url": "",
|
|
94
112
|
"api_key": "",
|
|
@@ -96,6 +114,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
96
114
|
"model": "",
|
|
97
115
|
"context_window": 200000,
|
|
98
116
|
"request_timeout": DEFAULT_REQUEST_TIMEOUT,
|
|
117
|
+
"single_pass_primary_max_tokens": DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
118
|
+
"single_pass_repair_max_tokens": DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
99
119
|
"diagnostic_logging": False,
|
|
100
120
|
"thinking": dict(DEFAULT_THINKING),
|
|
101
121
|
},
|
|
@@ -148,6 +168,19 @@ def _normalize_legacy_config(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
148
168
|
# the current default restore normal bounded evidence capture.
|
|
149
169
|
current["tool_evidence_mode"] = "off"
|
|
150
170
|
normalized["capture"] = current
|
|
171
|
+
llm = normalized.get("llm")
|
|
172
|
+
if llm is not None and not isinstance(llm, Mapping):
|
|
173
|
+
raise ValueError("invalid memleaf llm settings")
|
|
174
|
+
if isinstance(llm, Mapping):
|
|
175
|
+
current_llm = dict(llm)
|
|
176
|
+
# Old configs often omitted protocol and relied on the provider name.
|
|
177
|
+
# Preserve only exact built-in identifiers; custom aliases never
|
|
178
|
+
# override an explicitly configured protocol.
|
|
179
|
+
if not isinstance(current_llm.get("protocol"), str) or not current_llm.get("protocol", "").strip():
|
|
180
|
+
legacy_protocol = legacy_protocol_for_provider(current_llm.get("provider"))
|
|
181
|
+
if legacy_protocol is not None:
|
|
182
|
+
current_llm["protocol"] = legacy_protocol
|
|
183
|
+
normalized["llm"] = current_llm
|
|
151
184
|
return normalized
|
|
152
185
|
|
|
153
186
|
|
|
@@ -210,7 +243,17 @@ def load_config(path: Path | str, *, vault: Path | str | None = None) -> dict[st
|
|
|
210
243
|
if not isinstance(llm, Mapping):
|
|
211
244
|
raise ValueError("invalid memleaf llm settings")
|
|
212
245
|
llm = dict(llm)
|
|
246
|
+
llm["provider_family"] = normalize_provider_family(llm.get("provider_family"))
|
|
247
|
+
llm["protocol"] = normalize_protocol(llm.get("protocol"), provider=llm.get("provider"))
|
|
213
248
|
llm["request_timeout"] = _normalize_request_timeout(llm.get("request_timeout", DEFAULT_REQUEST_TIMEOUT))
|
|
249
|
+
llm["single_pass_primary_max_tokens"] = _normalize_single_pass_token_limit(
|
|
250
|
+
llm.get("single_pass_primary_max_tokens"),
|
|
251
|
+
field="single_pass_primary_max_tokens", default=DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
252
|
+
)
|
|
253
|
+
llm["single_pass_repair_max_tokens"] = _normalize_single_pass_token_limit(
|
|
254
|
+
llm.get("single_pass_repair_max_tokens"),
|
|
255
|
+
field="single_pass_repair_max_tokens", default=DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
256
|
+
)
|
|
214
257
|
llm["thinking"] = _normalize_thinking_settings(llm.get("thinking"))
|
|
215
258
|
if type(llm.get("diagnostic_logging", False)) is not bool:
|
|
216
259
|
raise ValueError("invalid memleaf llm.diagnostic_logging")
|
|
@@ -252,9 +295,21 @@ def save_config(path: Path | str, config: Mapping[str, Any]) -> None:
|
|
|
252
295
|
if not isinstance(llm, Mapping):
|
|
253
296
|
raise ValueError("invalid memleaf llm settings")
|
|
254
297
|
normalized_llm = dict(llm)
|
|
298
|
+
normalized_llm["provider_family"] = normalize_provider_family(normalized_llm.get("provider_family"))
|
|
299
|
+
normalized_llm["protocol"] = normalize_protocol(
|
|
300
|
+
normalized_llm.get("protocol"), provider=normalized_llm.get("provider")
|
|
301
|
+
)
|
|
255
302
|
normalized_llm["request_timeout"] = _normalize_request_timeout(
|
|
256
303
|
normalized_llm.get("request_timeout", DEFAULT_REQUEST_TIMEOUT)
|
|
257
304
|
)
|
|
305
|
+
normalized_llm["single_pass_primary_max_tokens"] = _normalize_single_pass_token_limit(
|
|
306
|
+
normalized_llm.get("single_pass_primary_max_tokens"),
|
|
307
|
+
field="single_pass_primary_max_tokens", default=DEFAULT_SINGLE_PASS_PRIMARY_MAX_TOKENS,
|
|
308
|
+
)
|
|
309
|
+
normalized_llm["single_pass_repair_max_tokens"] = _normalize_single_pass_token_limit(
|
|
310
|
+
normalized_llm.get("single_pass_repair_max_tokens"),
|
|
311
|
+
field="single_pass_repair_max_tokens", default=DEFAULT_SINGLE_PASS_REPAIR_MAX_TOKENS,
|
|
312
|
+
)
|
|
258
313
|
normalized_llm["thinking"] = _normalize_thinking_settings(normalized_llm.get("thinking"))
|
|
259
314
|
diagnostic_logging = normalized_llm.get("diagnostic_logging", False)
|
|
260
315
|
if type(diagnostic_logging) is not bool:
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Outbound-request accounting and advisory extraction latency metrics.
|
|
2
|
+
|
|
3
|
+
Ten seconds is a performance target, not permission to cancel useful work or
|
|
4
|
+
discard a valid memory. The transport owns the configured request timeout;
|
|
5
|
+
this module only limits request count and observes complete-turn latency.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Iterable, Mapping
|
|
12
|
+
|
|
13
|
+
from .llm import ModelError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MAX_MODEL_REQUESTS = 2
|
|
17
|
+
TARGET_TOTAL_SECONDS = 10.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SinglePassBudgetBackend:
|
|
21
|
+
"""Limit one logical B3 turn to two actual provider requests.
|
|
22
|
+
|
|
23
|
+
``single_pass_safe`` routes map one complete() call to one request, without
|
|
24
|
+
hidden host-to-API fallback. The optional durable reservation preserves
|
|
25
|
+
consumed attempts across worker restarts. Neither the first request nor
|
|
26
|
+
its repair overrides the transport's configured ``llm.request_timeout``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
backend: Any,
|
|
32
|
+
*,
|
|
33
|
+
reserve_request: Callable[[], int | None] | None = None,
|
|
34
|
+
):
|
|
35
|
+
if not hasattr(backend, "complete"):
|
|
36
|
+
raise TypeError("single-pass backend must expose complete()")
|
|
37
|
+
if reserve_request is not None and not callable(reserve_request):
|
|
38
|
+
raise TypeError("reserve_request must be callable")
|
|
39
|
+
self._backend = backend
|
|
40
|
+
self._requests = 0
|
|
41
|
+
self._reserve_request = reserve_request
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def provider(self) -> str:
|
|
45
|
+
return str(getattr(self._backend, "provider", "unknown"))
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def model(self) -> str:
|
|
49
|
+
return str(getattr(self._backend, "model", "unknown"))
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def single_pass_safe(self) -> bool:
|
|
53
|
+
"""Preserve the underlying adapter/route request-boundary guarantee."""
|
|
54
|
+
|
|
55
|
+
return getattr(self._backend, "single_pass_safe", False) is True
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def single_pass_protocol(self) -> bool:
|
|
59
|
+
"""Preserve protocol identity while this wrapper enforces request count."""
|
|
60
|
+
|
|
61
|
+
return getattr(self._backend, "single_pass_protocol", False) is True
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def parallel_safe(self) -> bool:
|
|
65
|
+
return getattr(self._backend, "parallel_safe", False) is True
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def structured_batch_safe(self) -> bool:
|
|
69
|
+
return getattr(self._backend, "structured_batch_safe", False) is True
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def request_count(self) -> int:
|
|
73
|
+
return self._requests
|
|
74
|
+
|
|
75
|
+
def _request_ordinal(self, *, purpose: str) -> int:
|
|
76
|
+
if self._requests >= MAX_MODEL_REQUESTS:
|
|
77
|
+
raise ModelError(
|
|
78
|
+
"single-pass extraction budget exhausted",
|
|
79
|
+
code="model_timeout",
|
|
80
|
+
stage=purpose or "single_pass",
|
|
81
|
+
)
|
|
82
|
+
if self._reserve_request is None:
|
|
83
|
+
return self._requests + 1
|
|
84
|
+
try:
|
|
85
|
+
ordinal = self._reserve_request()
|
|
86
|
+
except Exception as error:
|
|
87
|
+
raise ModelError(
|
|
88
|
+
"single-pass extraction request budget cannot be reserved",
|
|
89
|
+
code="model_timeout",
|
|
90
|
+
stage=purpose or "single_pass",
|
|
91
|
+
) from error
|
|
92
|
+
if type(ordinal) is not int or not 1 <= ordinal <= MAX_MODEL_REQUESTS:
|
|
93
|
+
raise ModelError(
|
|
94
|
+
"single-pass extraction budget exhausted",
|
|
95
|
+
code="model_timeout",
|
|
96
|
+
stage=purpose or "single_pass",
|
|
97
|
+
)
|
|
98
|
+
return ordinal
|
|
99
|
+
|
|
100
|
+
def complete(
|
|
101
|
+
self,
|
|
102
|
+
prompt: str,
|
|
103
|
+
*,
|
|
104
|
+
system: str = "",
|
|
105
|
+
purpose: str = "",
|
|
106
|
+
temperature: float = 0.0,
|
|
107
|
+
) -> str:
|
|
108
|
+
self._request_ordinal(purpose=purpose)
|
|
109
|
+
# Count only after durable reservation succeeds. A kill from this point
|
|
110
|
+
# onward still consumes that attempt, without imposing a wall deadline.
|
|
111
|
+
self._requests += 1
|
|
112
|
+
return self._backend.complete(
|
|
113
|
+
prompt,
|
|
114
|
+
system=system,
|
|
115
|
+
purpose=purpose,
|
|
116
|
+
temperature=temperature,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def consume_call_metrics(self) -> dict[str, Any]:
|
|
120
|
+
consume = getattr(self._backend, "consume_call_metrics", None)
|
|
121
|
+
if not callable(consume):
|
|
122
|
+
return {}
|
|
123
|
+
try:
|
|
124
|
+
value = consume()
|
|
125
|
+
except Exception:
|
|
126
|
+
return {}
|
|
127
|
+
return dict(value) if isinstance(value, Mapping) else {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ExtractionTiming:
|
|
131
|
+
"""Observe one processing attempt; never authorize or reject a write.
|
|
132
|
+
|
|
133
|
+
This monotonic timer includes local planning/context, model work, validation
|
|
134
|
+
and commit. A worker restart begins a new measured attempt; persisted wall
|
|
135
|
+
timestamps from older releases are not latency-based write restrictions.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, *, clock: Any = None):
|
|
139
|
+
self._clock = time.monotonic if clock is None else clock
|
|
140
|
+
self._started = self._clock()
|
|
141
|
+
self._commit_started: float | None = None
|
|
142
|
+
|
|
143
|
+
def begin_commit(self) -> None:
|
|
144
|
+
self._commit_started = self._clock()
|
|
145
|
+
|
|
146
|
+
def finish(self, *, failed: bool = False) -> dict[str, int]:
|
|
147
|
+
ended = self._clock()
|
|
148
|
+
seconds = max(0.0, ended - self._started)
|
|
149
|
+
commit_started = self._commit_started
|
|
150
|
+
planning_end = ended if commit_started is None else commit_started
|
|
151
|
+
return {
|
|
152
|
+
"target_duration_ms": int(TARGET_TOTAL_SECONDS * 1000),
|
|
153
|
+
"turn_count": 1,
|
|
154
|
+
"failed_turn_count": int(failed),
|
|
155
|
+
"over_target_turn_count": int(seconds > TARGET_TOTAL_SECONDS),
|
|
156
|
+
"successful_within_target_count": int(not failed and seconds <= TARGET_TOTAL_SECONDS),
|
|
157
|
+
"commit_accepted_count": int(not failed and commit_started is not None),
|
|
158
|
+
"total_duration_ms": int(seconds * 1000),
|
|
159
|
+
"max_turn_duration_ms": int(seconds * 1000),
|
|
160
|
+
"planning_duration_ms": int(max(0.0, planning_end - self._started) * 1000),
|
|
161
|
+
"commit_duration_ms": int(max(0.0, ended - planning_end) * 1000),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def aggregate_extraction_metrics(values: Iterable[Mapping[str, Any]]) -> dict[str, int]:
|
|
166
|
+
"""Sum allowlisted numeric metrics; never copy arbitrary content to status."""
|
|
167
|
+
|
|
168
|
+
summed = (
|
|
169
|
+
"turn_count", "failed_turn_count", "over_target_turn_count",
|
|
170
|
+
"successful_within_target_count", "commit_accepted_count", "total_duration_ms",
|
|
171
|
+
"planning_duration_ms", "commit_duration_ms",
|
|
172
|
+
)
|
|
173
|
+
result = {key: 0 for key in summed}
|
|
174
|
+
result["target_duration_ms"] = int(TARGET_TOTAL_SECONDS * 1000)
|
|
175
|
+
result["max_turn_duration_ms"] = 0
|
|
176
|
+
for value in values:
|
|
177
|
+
if not isinstance(value, Mapping):
|
|
178
|
+
continue
|
|
179
|
+
for key in (*summed, "max_turn_duration_ms"):
|
|
180
|
+
item = value.get(key)
|
|
181
|
+
if type(item) is not int or item < 0:
|
|
182
|
+
continue
|
|
183
|
+
if key == "max_turn_duration_ms":
|
|
184
|
+
result[key] = max(result[key], item)
|
|
185
|
+
else:
|
|
186
|
+
result[key] += item
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def budget_single_pass_backend(
|
|
191
|
+
backend: Any,
|
|
192
|
+
*,
|
|
193
|
+
reserve_request: Callable[[], int | None] | None = None,
|
|
194
|
+
) -> SinglePassBudgetBackend:
|
|
195
|
+
if isinstance(backend, SinglePassBudgetBackend):
|
|
196
|
+
return backend
|
|
197
|
+
return SinglePassBudgetBackend(
|
|
198
|
+
backend,
|
|
199
|
+
reserve_request=reserve_request,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = [
|
|
204
|
+
"MAX_MODEL_REQUESTS",
|
|
205
|
+
"TARGET_TOTAL_SECONDS",
|
|
206
|
+
"ExtractionTiming",
|
|
207
|
+
"aggregate_extraction_metrics",
|
|
208
|
+
"SinglePassBudgetBackend",
|
|
209
|
+
"budget_single_pass_backend",
|
|
210
|
+
]
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Capability checks for unified automatic extraction.
|
|
2
2
|
|
|
3
|
-
Protocol compatibility and
|
|
4
|
-
A backend may understand the B3
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
8/10-second budget only to ``single_pass_safe`` transports.
|
|
3
|
+
Protocol compatibility and transport request accounting are separate.
|
|
4
|
+
A backend may understand the B3 contract without exposing a bounded transport.
|
|
5
|
+
``single_pass_safe`` identifies routes suitable for outbound-request counting;
|
|
6
|
+
HTTP requests use the configured timeout, not the advisory ten-second target.
|
|
8
7
|
"""
|
|
9
8
|
from __future__ import annotations
|
|
10
9
|
|
|
@@ -33,25 +32,25 @@ def _direct_protocol_capable(backend: Any) -> bool:
|
|
|
33
32
|
|
|
34
33
|
if backend is None:
|
|
35
34
|
return False
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return declared
|
|
35
|
+
# Raw Python callbacks are adapted by ModelExecutor into CallableBackend.
|
|
36
|
+
# Preserve their established prompt-level B3 compatibility without
|
|
37
|
+
# claiming any transport boundary inside caller-owned code. An explicit
|
|
38
|
+
# callback override can still opt out.
|
|
41
39
|
if isinstance(backend, CallableBackend):
|
|
42
40
|
override = _callable_protocol_override(backend)
|
|
43
41
|
if override is not None:
|
|
44
42
|
return override
|
|
45
|
-
# Raw Python callbacks are adapted by ModelExecutor into
|
|
46
|
-
# CallableBackend. They can consume memleaf's B3 prompt, but are not
|
|
47
|
-
# strict-deadline safe because caller-owned code may ignore
|
|
48
|
-
# timeout/cancellation entirely.
|
|
49
43
|
return True
|
|
44
|
+
# Protocol support is independent of request-boundary safety. A backend
|
|
45
|
+
# must not gain B3 merely because complete() happens to be bounded.
|
|
46
|
+
declared = getattr(backend, "single_pass_protocol", None)
|
|
47
|
+
if isinstance(declared, bool):
|
|
48
|
+
return declared
|
|
50
49
|
return False
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
def supports_single_pass_protocol(backend: Any) -> bool:
|
|
54
|
-
"""Return B3 protocol capability without claiming
|
|
53
|
+
"""Return B3 protocol capability without claiming transport guarantees."""
|
|
55
54
|
|
|
56
55
|
if _direct_protocol_capable(backend):
|
|
57
56
|
return True
|