ph-core 0.2.0__tar.gz → 0.4.0__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.
- {ph_core-0.2.0 → ph_core-0.4.0}/PKG-INFO +3 -3
- {ph_core-0.2.0 → ph_core-0.4.0}/README.md +2 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/pyproject.toml +1 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/__init__.py +1 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/inbox.py +4 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/registry.py +7 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/types.py +38 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent_loop/driver.py +164 -57
- ph_core-0.4.0/src/ph/cancel.py +208 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/autonomous.py +13 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/revert.py +2 -15
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/sandbox.py +2 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/__init__.py +6 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/context.py +423 -48
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/events.py +13 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/key.py +6 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/loader.py +86 -30
- ph_core-0.4.0/src/ph/indexable.py +279 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/json.py +24 -3
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/keys.py +3 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/lingering.py +2 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/__init__.py +2 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/adapter.py +50 -6
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/assembler.py +81 -6
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/media.py +6 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/replay.py +36 -10
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/retry.py +38 -23
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/structured.py +61 -14
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/types.py +58 -0
- ph_core-0.4.0/src/ph/locks.py +80 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/orphans.py +87 -13
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/paths.py +89 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/__init__.py +12 -3
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/checkpoint_policy.py +21 -7
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/families.py +9 -1
- ph_core-0.4.0/src/ph/persistence/jsonl.py +880 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/lease.py +41 -14
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/lineage.py +15 -3
- ph_core-0.4.0/src/ph/persistence/opening.py +106 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/protocol.py +112 -18
- ph_core-0.4.0/src/ph/persistence/repair.py +426 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/turso.py +77 -69
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/resources.py +24 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/approval.py +94 -93
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/attachments.py +11 -10
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/changes.py +4 -6
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/code_runtime_stub.py +1 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/commands.py +18 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/credentials.py +88 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/fs.py +298 -39
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/goals.py +70 -17
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/jobs.py +15 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/permission_presets.py +4 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox.py +120 -14
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox_local.py +51 -6
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/schedule.py +58 -11
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/schedule_index.py +124 -20
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/settings.py +2 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/shell.py +22 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/skills.py +62 -8
- ph_core-0.4.0/src/ph/seams/spill.py +478 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/subagents.py +315 -59
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/subprocess.py +107 -19
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/telemetry.py +26 -12
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/telemetry_otel.py +9 -5
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/token_meter.py +9 -4
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/tui_screens.py +26 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/uploads.py +21 -19
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/user_questions.py +108 -94
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace.py +91 -13
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_agentfs.py +21 -10
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_git.py +172 -33
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_jj.py +21 -7
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/__init__.py +76 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/events.py +34 -3
- ph_core-0.4.0/src/ph/session/intents.py +366 -0
- ph_core-0.4.0/src/ph/session/journal.py +412 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/json.py +1 -1
- ph_core-0.4.0/src/ph/session/kinds.py +451 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/known_event_types.py +353 -10
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/session.py +239 -39
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/store.py +140 -11
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/surface.py +36 -6
- ph_core-0.4.0/src/ph/session/writers.py +143 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/system_prompt/assembly.py +57 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/__init__.py +18 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/builders.py +182 -11
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/git.py +1 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/jj.py +2 -4
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/text.py +16 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/__init__.py +29 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/batch.py +76 -7
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/ask_user.py +72 -7
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/bash_tool.py +18 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/fs_tools.py +65 -6
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/subagent_task.py +50 -23
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/code_mode.py +92 -12
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/definition.py +174 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/errors.py +65 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/json_schema.py +22 -5
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/registry.py +348 -19
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/timeout.py +14 -1
- ph_core-0.4.0/tests/test_agent_loop.py +1008 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_ask_user.py +119 -12
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_attachments.py +36 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_attachments_gc.py +13 -2
- ph_core-0.4.0/tests/test_cancel.py +171 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_code_mode.py +173 -4
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_context.py +254 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_dispatch.py +135 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_events.py +21 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_loader.py +198 -2
- ph_core-0.4.0/tests/test_credential_holds.py +154 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_derive.py +26 -18
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_fold_laws.py +32 -24
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_fork.py +40 -14
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_fs.py +205 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_goals.py +76 -6
- ph_core-0.4.0/tests/test_indexable.py +182 -0
- ph_core-0.4.0/tests/test_intent_kinds.py +227 -0
- ph_core-0.4.0/tests/test_intents.py +424 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_invariants.py +5 -3
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_keys.py +14 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_llm.py +154 -2
- ph_core-0.4.0/tests/test_locks.py +95 -0
- ph_core-0.4.0/tests/test_log_writers.py +394 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_loop_policies.py +141 -14
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_orphans.py +99 -7
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_paths.py +65 -0
- ph_core-0.4.0/tests/test_persistence.py +844 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_persistence_backends.py +212 -20
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_prefix_stability.py +31 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_registration_ownership.py +22 -1
- ph_core-0.4.0/tests/test_repair.py +1048 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sandbox_allow.py +134 -5
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sandbox_local.py +46 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_schedule.py +132 -7
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_seams.py +357 -34
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_session_admit.py +45 -5
- ph_core-0.4.0/tests/test_session_append.py +570 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_session_event.py +1 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_skills_progressive.py +52 -0
- ph_core-0.4.0/tests/test_spill_sweep.py +303 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_structured.py +73 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_subagent_grant.py +171 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_subagent_task.py +118 -0
- ph_core-0.4.0/tests/test_subprocess.py +302 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_surface.py +51 -28
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_telemetry.py +64 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_tools_batch.py +98 -1
- ph_core-0.4.0/tests/test_tools_idempotency.py +240 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_tools_pipeline.py +124 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_agentfs.py +44 -1
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_checkpoint.py +40 -9
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_git.py +199 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_jj.py +33 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_reconcile.py +88 -2
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_retention.py +4 -3
- ph_core-0.2.0/src/ph/cancel.py +0 -96
- ph_core-0.2.0/src/ph/persistence/jsonl.py +0 -455
- ph_core-0.2.0/src/ph/persistence/repair.py +0 -260
- ph_core-0.2.0/src/ph/seams/spill.py +0 -263
- ph_core-0.2.0/tests/test_agent_loop.py +0 -425
- ph_core-0.2.0/tests/test_persistence.py +0 -345
- ph_core-0.2.0/tests/test_repair.py +0 -485
- ph_core-0.2.0/tests/test_session_append.py +0 -355
- ph_core-0.2.0/tests/test_spill_sweep.py +0 -150
- {ph_core-0.2.0 → ph_core-0.4.0}/.gitignore +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/LICENSE +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent_loop/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent_loop/invariant.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/bundles/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/bundles/base.yaml +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/bundles/headless.yaml +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/workspaces.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/catalog.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/errors.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/plugin.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/documents.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/dimensions.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/fake.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/py.typed +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/_names.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/_registry.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/_restriction.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/code_runtime.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/compaction.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/containment.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/diagnostics.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/invariants.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox_allow.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox_egress.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/scope_invariant.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/skills_invariant.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/topology.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/tui_status.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_provision.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_scratch.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/selectors.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/derive.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/folds.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/invariant.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/request_header.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/system_prompt/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/system_prompt/memory.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/anthropic_wire.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/diagnostics.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/folds.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/skills.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/stub_sandbox.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/stub_subagent.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/stub_workspace.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/__init__.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/attach_tool.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/invariant.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/presentation.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/prompt.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/sdk.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/wire.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_attach_tool.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_catalog.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_changes.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_commands_sandbox.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_commands_workspaces.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_containment.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_containment_ladder.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_plugin.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_diagnostics.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_dimensions.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_docs_seams.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_documents.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_exhaustive_dispatch.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_json.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_json_schema.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_layering.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_lingering.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_media_degrade.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_memory.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_resources.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sandbox_egress.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sdk.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_selectors.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_telemetry_otel.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_text.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_tools_registry.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_wire_forms.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_lifecycle.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_provision.py +0 -0
- {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_scratch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ph-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: pH core: the cordis plugin subset, the session log, the LLM vocabulary, and the agent loop.
|
|
5
5
|
Project-URL: Homepage, https://github.com/chastabor/pH
|
|
6
6
|
Project-URL: Repository, https://github.com/chastabor/pH
|
|
@@ -153,11 +153,11 @@ phern config --row containment --profile tui # one row's knobs, defaults and wha
|
|
|
153
153
|
| `containment` | `tier`, `childTier`, `strict` | **unset** — which is not `advisory`: unset means nobody chose, so a tier provider a profile layered is used |
|
|
154
154
|
| `workspace-lifecycle` | `access`, `provision` | `write` |
|
|
155
155
|
| `jobs-local` | `concurrency` (per job kind) | unset |
|
|
156
|
-
| `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs` | `3`, `500`, `20000` |
|
|
156
|
+
| `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `countAllRetries` | `3`, `500`, `20000`, `false` — only `llm-retry`'s own retries spend its budget; `true` counts every row's, compaction's included |
|
|
157
157
|
| `session-telemetry` | `enabled`, `path` | `enabled: false` in `ph-base` |
|
|
158
158
|
| `subagent-presets` | `presets` | empty — a menu, never a grant: selecting a preset never widens what the parent itself holds |
|
|
159
159
|
| `skills-progressive` | `paths` | **empty on purpose** — scanning a well-known directory would make "install a skill" mean "drop a file somewhere" (I7) |
|
|
160
|
-
| `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs` | `3`, `12`, `80000`, `1800000` |
|
|
160
|
+
| `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs`, `tokenSources` | `3`, `12`, `80000`, `1800000`, `[own]` — add `compaction` and `children` to charge summaries and subagents to `maxTokens` |
|
|
161
161
|
| `subprocess-local` | `scrub`, `keep`, `maxOutputBytes` | `8388608` |
|
|
162
162
|
| `tool-attach` | `maxBytes` | `33554432` |
|
|
163
163
|
|
|
@@ -119,11 +119,11 @@ phern config --row containment --profile tui # one row's knobs, defaults and wha
|
|
|
119
119
|
| `containment` | `tier`, `childTier`, `strict` | **unset** — which is not `advisory`: unset means nobody chose, so a tier provider a profile layered is used |
|
|
120
120
|
| `workspace-lifecycle` | `access`, `provision` | `write` |
|
|
121
121
|
| `jobs-local` | `concurrency` (per job kind) | unset |
|
|
122
|
-
| `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs` | `3`, `500`, `20000` |
|
|
122
|
+
| `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `countAllRetries` | `3`, `500`, `20000`, `false` — only `llm-retry`'s own retries spend its budget; `true` counts every row's, compaction's included |
|
|
123
123
|
| `session-telemetry` | `enabled`, `path` | `enabled: false` in `ph-base` |
|
|
124
124
|
| `subagent-presets` | `presets` | empty — a menu, never a grant: selecting a preset never widens what the parent itself holds |
|
|
125
125
|
| `skills-progressive` | `paths` | **empty on purpose** — scanning a well-known directory would make "install a skill" mean "drop a file somewhere" (I7) |
|
|
126
|
-
| `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs` | `3`, `12`, `80000`, `1800000` |
|
|
126
|
+
| `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs`, `tokenSources` | `3`, `12`, `80000`, `1800000`, `[own]` — add `compaction` and `children` to charge summaries and subagents to `maxTokens` |
|
|
127
127
|
| `subprocess-local` | `scrub`, `keep`, `maxOutputBytes` | `8388608` |
|
|
128
128
|
| `tool-attach` | `maxBytes` | `33554432` |
|
|
129
129
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ph-core"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "pH core: the cordis plugin subset, the session log, the LLM vocabulary, and the agent loop."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -27,8 +27,11 @@ from pydantic import ConfigDict
|
|
|
27
27
|
from ..json import JsonObject
|
|
28
28
|
from ..llm.types import Message
|
|
29
29
|
from ..session import Session
|
|
30
|
+
from ..session.writers import log_writer
|
|
30
31
|
from ..wire import WireModel
|
|
31
32
|
|
|
33
|
+
_LOG = log_writer(__name__)
|
|
34
|
+
|
|
32
35
|
__all__ = ["Inbox", "InboxNotifications", "InboxTarget"]
|
|
33
36
|
|
|
34
37
|
InboxTarget: TypeAlias = Literal["next-turn", "next-step"]
|
|
@@ -163,7 +166,7 @@ class Inbox:
|
|
|
163
166
|
# The durable event commits BEFORE the live projection mutates, so a
|
|
164
167
|
# synchronous `session/event` observer sees the pre-splice lists and can
|
|
165
168
|
# reconstruct exactly what was removed from the normalized coordinates.
|
|
166
|
-
self._session
|
|
169
|
+
_LOG.append(self._session, "agent/inbox/spliced", splice.to_wire())
|
|
167
170
|
removed = pending[start : start + delete_count]
|
|
168
171
|
pending[start : start + delete_count] = inserted
|
|
169
172
|
if discard_removed:
|
|
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
|
|
|
17
17
|
|
|
18
18
|
from ..cordis import Context, events, plugin
|
|
19
19
|
from ..keys import AGENT, AGENTS, SESSIONS
|
|
20
|
-
from ..session import Session
|
|
20
|
+
from ..session import Session, session_written
|
|
21
21
|
from .types import (
|
|
22
22
|
AgentDriver,
|
|
23
23
|
AgentHandle,
|
|
@@ -178,6 +178,12 @@ class AgentRegistry:
|
|
|
178
178
|
# `_forget` rides the scope's own teardown, so the announcement and the
|
|
179
179
|
# roster drop happen exactly once whichever way the scope goes.
|
|
180
180
|
await agent.ctx.dispose()
|
|
181
|
+
# The teardown just appended to this agent's log — its workspace records
|
|
182
|
+
# `workspace/disposed` as it lets go — so written here, while the
|
|
183
|
+
# persistence row is still mounted, rather than whenever the mount
|
|
184
|
+
# unwinds. A mount's own unwind is `write_on_unwind`'s (F2).
|
|
185
|
+
if agent.session is not None:
|
|
186
|
+
await session_written(self.ctx, agent.session)
|
|
181
187
|
|
|
182
188
|
|
|
183
189
|
@plugin("agent", inject=[SESSIONS])
|
|
@@ -12,6 +12,7 @@ a listener's signature *is* the contract: the limits and permissions plugins
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
from collections.abc import Mapping
|
|
15
16
|
from dataclasses import dataclass
|
|
16
17
|
from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
|
|
17
18
|
|
|
@@ -170,6 +171,13 @@ class RequestProposal:
|
|
|
170
171
|
"""`agent/request`: the call config the loop proposes for one request."""
|
|
171
172
|
|
|
172
173
|
agent: AgentHandle
|
|
174
|
+
session: Session
|
|
175
|
+
"""Not optional here, for `PreStepRequest.session`'s reason (L5).
|
|
176
|
+
|
|
177
|
+
`input-offload` is the listener that had reached through the handle, and it
|
|
178
|
+
paid the same price `llm-retry` did: a `None` its only producer cannot
|
|
179
|
+
create, guarded by returning early — so the feature turned itself off rather
|
|
180
|
+
than failing."""
|
|
173
181
|
turn: int
|
|
174
182
|
step: int
|
|
175
183
|
config: LlmCallConfig
|
|
@@ -180,10 +188,37 @@ class RequestFailure:
|
|
|
180
188
|
"""`agent/request-error`: a request that ended in `error` or `aborted`."""
|
|
181
189
|
|
|
182
190
|
agent: AgentHandle
|
|
191
|
+
session: Session
|
|
192
|
+
"""Not optional here, for `PreStepRequest.session`'s reason (L5).
|
|
193
|
+
|
|
194
|
+
Reaching through the handle instead made `llm-retry` guard a `None` its only
|
|
195
|
+
producer cannot create, and that guard does not fail loudly: it logs at
|
|
196
|
+
debug and declines the retry, so a route that had retries configured quietly
|
|
197
|
+
had none."""
|
|
183
198
|
turn: int
|
|
184
199
|
step: int
|
|
185
200
|
provider: str
|
|
186
201
|
failure: LlmFailure
|
|
202
|
+
retries_by: Mapping[str, int]
|
|
203
|
+
"""How many times the loop has already retried this step, by the row that asked (G13).
|
|
204
|
+
|
|
205
|
+
Keyed by the plugin whose listener granted each retry, as
|
|
206
|
+
`Context.waterfall_attributed` reports it — `""` for a listener outside any
|
|
207
|
+
plugin. Counted by the driver, because every retry passes through it.
|
|
208
|
+
Handed to the policy rather than re-derived there, so a row budgets against
|
|
209
|
+
its own retries (`retries_by["llm-retry"]`) or against all of them
|
|
210
|
+
(`retries`) by choosing a number, not by folding a log only some rows write.
|
|
211
|
+
|
|
212
|
+
**Per step, per agent, by construction**: the count is a local in the
|
|
213
|
+
driver's retry loop, so two subagents at the same coordinates cannot share
|
|
214
|
+
it (I4) — the bug a count held on the listener had. Nothing is lost on a
|
|
215
|
+
resume either, because a resumed session starts a new turn and never
|
|
216
|
+
re-enters the step it was retrying."""
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def retries(self) -> int:
|
|
220
|
+
"""Every retry this step has had, whichever row asked."""
|
|
221
|
+
return sum(self.retries_by.values())
|
|
187
222
|
|
|
188
223
|
|
|
189
224
|
@dataclass(frozen=True, slots=True)
|
|
@@ -192,6 +227,9 @@ class RequestErrorAction:
|
|
|
192
227
|
|
|
193
228
|
kind: Literal["retry"]
|
|
194
229
|
delay_ms: int = 0
|
|
230
|
+
"""How long the loop waits before the retry. The loop sleeps it, once the
|
|
231
|
+
waterfall has settled — never the row — so a listener that refuses the retry
|
|
232
|
+
costs no wait."""
|
|
195
233
|
|
|
196
234
|
|
|
197
235
|
@dataclass(frozen=True, slots=True)
|
|
@@ -41,7 +41,8 @@ seam unless something shadowed it for this agent alone.
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
43
|
import logging
|
|
44
|
-
from collections
|
|
44
|
+
from collections import Counter
|
|
45
|
+
from collections.abc import Mapping, Sequence
|
|
45
46
|
from dataclasses import dataclass, field
|
|
46
47
|
from typing import Any
|
|
47
48
|
|
|
@@ -64,7 +65,7 @@ from ..cordis import Context, settled, settled_or_none
|
|
|
64
65
|
from ..json import as_int
|
|
65
66
|
from ..keys import LLM, SYSTEM_PROMPT
|
|
66
67
|
from ..llm.adapter import LlmError
|
|
67
|
-
from ..llm.assembler import BlockAssembler
|
|
68
|
+
from ..llm.assembler import BlockAssembler, named_call
|
|
68
69
|
from ..llm.types import (
|
|
69
70
|
ContentBlock,
|
|
70
71
|
FinishReason,
|
|
@@ -72,7 +73,6 @@ from ..llm.types import (
|
|
|
72
73
|
LlmCallConfig,
|
|
73
74
|
LlmFailure,
|
|
74
75
|
Message,
|
|
75
|
-
PluginSource,
|
|
76
76
|
TokenUsage,
|
|
77
77
|
ToolCallBlock,
|
|
78
78
|
create_assistant_message,
|
|
@@ -80,15 +80,13 @@ from ..llm.types import (
|
|
|
80
80
|
)
|
|
81
81
|
from ..session import Session, SurfaceIntent
|
|
82
82
|
from ..session.request_header import EpochHeader, RequestContext, canonical_header, header_equals
|
|
83
|
-
from ..
|
|
84
|
-
|
|
85
|
-
join_context_sections,
|
|
86
|
-
render_context_sections,
|
|
87
|
-
render_prompt,
|
|
88
|
-
)
|
|
83
|
+
from ..session.writers import log_writer
|
|
84
|
+
from ..system_prompt.assembly import PromptAssembly, context_message, render_prompt
|
|
89
85
|
from ..tools.batch import execute_tool_calls
|
|
90
86
|
from ..tools.errors import error_info
|
|
91
87
|
|
|
88
|
+
_LOG = log_writer(__name__)
|
|
89
|
+
|
|
92
90
|
__all__ = ["AgentCanceled", "ReactLoopAgent"]
|
|
93
91
|
|
|
94
92
|
log = logging.getLogger("ph.agent_loop")
|
|
@@ -149,7 +147,6 @@ class ReactLoopAgent:
|
|
|
149
147
|
self.max_parallel_tool_calls = max_parallel_tool_calls
|
|
150
148
|
self._phase = _Phase(turn=_last_turn_of(session))
|
|
151
149
|
self._request_header_logged = False
|
|
152
|
-
self._context_snapshot: str | None = None
|
|
153
150
|
self._idle = anyio.Event()
|
|
154
151
|
self._idle.set()
|
|
155
152
|
self.inbox = Inbox(
|
|
@@ -276,11 +273,21 @@ class ReactLoopAgent:
|
|
|
276
273
|
|
|
277
274
|
async def _pre_step(self, target: InboxTarget, turn: int, step: int) -> _PreparedStep:
|
|
278
275
|
self._throw_if_canceled()
|
|
279
|
-
claimed
|
|
276
|
+
# **Assembled before the batch is claimed** (C4). `claim` is durable —
|
|
277
|
+
# it appends `agent/inbox/spliced`, which is what takes the messages out
|
|
278
|
+
# of the inbox for good — and `assemble` is an await that a person's
|
|
279
|
+
# interrupt can land in. Claimed first, a cancel there consumed the
|
|
280
|
+
# prompt and ran nothing: the typed line was gone from the inbox and
|
|
281
|
+
# never reached a model call. Nothing here needs the batch, so the
|
|
282
|
+
# ordering costs nothing and the claim now happens on the far side of
|
|
283
|
+
# the last cancel check before the step is proposed.
|
|
280
284
|
assembly = await self.ctx.require(SYSTEM_PROMPT).assemble(self.ctx, agent=self)
|
|
281
285
|
self._throw_if_canceled()
|
|
282
|
-
|
|
283
|
-
|
|
286
|
+
claimed = self.inbox.claim(target, turn)
|
|
287
|
+
# Only when the conversation the model is shown lacks it (C12) — see
|
|
288
|
+
# `context_message`.
|
|
289
|
+
context = context_message(assembly, self.session.derive_messages())
|
|
290
|
+
messages = (*claimed, context) if context is not None else tuple(claimed)
|
|
284
291
|
|
|
285
292
|
async def inner(request: PreStepRequest) -> PreStepDecision:
|
|
286
293
|
return PreStepDecision(kind="enter", messages=request.messages)
|
|
@@ -295,34 +302,22 @@ class ReactLoopAgent:
|
|
|
295
302
|
return _PreparedStep(kind="reject")
|
|
296
303
|
return _PreparedStep(kind="enter", messages=decision.messages, assembly=assembly)
|
|
297
304
|
|
|
298
|
-
def _project_context(self, assembly: PromptAssembly) -> Message | None:
|
|
299
|
-
"""Materialize `context()` providers, but only when the text changed.
|
|
300
|
-
|
|
301
|
-
This is the whole reason `context()` exists separately from `section`:
|
|
302
|
-
re-sending unchanged context on every step would invalidate the cached
|
|
303
|
-
prefix each turn (A12).
|
|
304
|
-
"""
|
|
305
|
-
sections = render_context_sections(assembly)
|
|
306
|
-
if not sections:
|
|
307
|
-
return None
|
|
308
|
-
text = join_context_sections(sections)
|
|
309
|
-
if text == self._context_snapshot:
|
|
310
|
-
return None
|
|
311
|
-
self._context_snapshot = text
|
|
312
|
-
return create_user_message(
|
|
313
|
-
content=[{"type": "text", "text": text}],
|
|
314
|
-
source=PluginSource(
|
|
315
|
-
plugin="ph.system-prompt", form="snapshot", sections=list(sections)
|
|
316
|
-
),
|
|
317
|
-
)
|
|
318
|
-
|
|
319
305
|
async def _turn(self) -> bool:
|
|
320
306
|
phase = self._phase
|
|
321
307
|
self._throw_if_canceled()
|
|
322
308
|
turn = phase.turn + 1
|
|
323
|
-
self.session
|
|
309
|
+
_LOG.append(self.session, "turn/start", {"turn": turn})
|
|
324
310
|
phase.turn = turn
|
|
325
311
|
turn_ends: TurnEndReason | None = None
|
|
312
|
+
capped = False
|
|
313
|
+
"""Whether any step in this turn stopped at `max_tokens`.
|
|
314
|
+
|
|
315
|
+
Kept beside `turn_ends` rather than inside it, because the two answer
|
|
316
|
+
different questions: what ends the turn, and what the person should be
|
|
317
|
+
told about it. Folding the second into the first made a `max-tokens` step
|
|
318
|
+
*end the turn* even where the loop had more to do — see where it is read
|
|
319
|
+
below.
|
|
320
|
+
"""
|
|
326
321
|
target: InboxTarget = "next-turn"
|
|
327
322
|
try:
|
|
328
323
|
while True:
|
|
@@ -341,21 +336,26 @@ class ReactLoopAgent:
|
|
|
341
336
|
turn_ends = TurnEndReason(kind="completed")
|
|
342
337
|
return False
|
|
343
338
|
self._throw_if_canceled()
|
|
344
|
-
self.session
|
|
339
|
+
_LOG.append(self.session, "step/start", {"turn": turn, "step": step})
|
|
345
340
|
phase.step = step
|
|
346
341
|
try:
|
|
347
342
|
for message in decision.messages:
|
|
348
|
-
|
|
349
|
-
"user/message", message.to_wire(), SurfaceIntent("append")
|
|
343
|
+
_LOG.append(
|
|
344
|
+
self.session, "user/message", message.to_wire(), SurfaceIntent("append")
|
|
350
345
|
)
|
|
351
346
|
assert decision.assembly is not None
|
|
352
347
|
step_end = await self._step(decision.assembly)
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
|
|
356
|
-
|
|
348
|
+
# **The outcome is sticky; the stopping is not.** A step that
|
|
349
|
+
# hit `max_tokens` must still be reported as such at the end
|
|
350
|
+
# of the turn, however the turn actually finishes — but a
|
|
351
|
+
# `None` here means "tools ran, there is more to do", and
|
|
352
|
+
# holding the earlier reason for it ended the turn with a
|
|
353
|
+
# `tool/result` the model never saw. The two were one
|
|
354
|
+
# variable, so the sticky answer was also the deciding one.
|
|
355
|
+
capped = capped or (step_end is not None and step_end.kind == "max-tokens")
|
|
356
|
+
turn_ends = step_end
|
|
357
357
|
finally:
|
|
358
|
-
self.session
|
|
358
|
+
_LOG.append(self.session, "step/end", {"turn": turn, "step": step})
|
|
359
359
|
self._throw_if_canceled()
|
|
360
360
|
if turn_ends is not None and not self.inbox.next_step:
|
|
361
361
|
await self.ctx.serial("agent/turn-stopping", self, turn)
|
|
@@ -383,9 +383,10 @@ class ReactLoopAgent:
|
|
|
383
383
|
self._report(error)
|
|
384
384
|
raise
|
|
385
385
|
finally:
|
|
386
|
-
|
|
386
|
+
_LOG.append(
|
|
387
|
+
self.session,
|
|
387
388
|
"turn/end",
|
|
388
|
-
{"turn": turn, "reason": (turn_ends
|
|
389
|
+
{"turn": turn, "reason": _turn_reason(turn_ends, capped).to_wire()},
|
|
389
390
|
)
|
|
390
391
|
if not self.inbox.has_pending:
|
|
391
392
|
return False
|
|
@@ -398,6 +399,17 @@ class ReactLoopAgent:
|
|
|
398
399
|
self._throw_if_canceled()
|
|
399
400
|
system = render_prompt(assembly)
|
|
400
401
|
|
|
402
|
+
# **Which attempt this is, because a retried step keeps its coordinates**
|
|
403
|
+
# (G12). That is deliberate — `llm/retry` is legible in the log because
|
|
404
|
+
# of it — but it leaves `(turn, step)` naming a *step* where a model
|
|
405
|
+
# *call* is what needs the name. Two attempts minted the same tool-call
|
|
406
|
+
# ids and `recorded_steps` had to infer the boundary between them.
|
|
407
|
+
#
|
|
408
|
+
# **Counted here because this is the only place every retry passes**,
|
|
409
|
+
# by the row that asked (G13): the attempt is the total, and a policy's
|
|
410
|
+
# budget is its own share or the total as it chooses.
|
|
411
|
+
retries: Counter[str] = Counter()
|
|
412
|
+
attempt = 0
|
|
401
413
|
while True:
|
|
402
414
|
request = await self._build_request(turn, step, assembly, system)
|
|
403
415
|
assembler = BlockAssembler()
|
|
@@ -407,13 +419,28 @@ class ReactLoopAgent:
|
|
|
407
419
|
self._throw_if_canceled()
|
|
408
420
|
async for chunk in stream:
|
|
409
421
|
self._throw_if_canceled()
|
|
422
|
+
# **Named before it is logged** (G11): a tool call the
|
|
423
|
+
# provider did not name gets its id here, so the raw record
|
|
424
|
+
# and the assembled message agree about it. This is the only
|
|
425
|
+
# consumer that pairs a call to a result, and the only one
|
|
426
|
+
# holding the coordinates the id is built from.
|
|
427
|
+
chunk = named_call(chunk, turn, step, attempt)
|
|
410
428
|
# Raw chunks are logged before assembly, so the log carries
|
|
411
429
|
# token-level replay fidelity even for a stream that later
|
|
412
430
|
# fails.
|
|
413
431
|
chunk_seqs.append(
|
|
414
|
-
|
|
432
|
+
_LOG.append(
|
|
433
|
+
self.session,
|
|
415
434
|
"assistant/chunk",
|
|
416
|
-
{
|
|
435
|
+
{
|
|
436
|
+
"turn": turn,
|
|
437
|
+
"step": step,
|
|
438
|
+
# The call's own coordinate, so `recorded_steps`
|
|
439
|
+
# reads the boundary between two attempts rather
|
|
440
|
+
# than inferring it (G12).
|
|
441
|
+
"attempt": attempt,
|
|
442
|
+
"chunk": chunk.to_wire(),
|
|
443
|
+
},
|
|
417
444
|
).seq
|
|
418
445
|
)
|
|
419
446
|
assembler.push(chunk)
|
|
@@ -431,12 +458,31 @@ class ReactLoopAgent:
|
|
|
431
458
|
|
|
432
459
|
finish = assembler.finish
|
|
433
460
|
if finish.kind in ("error", "aborted"):
|
|
434
|
-
action = await self._request_error(turn, step, request, finish)
|
|
461
|
+
action, by = await self._request_error(turn, step, retries, request, finish)
|
|
435
462
|
if action is None or action.kind != "retry":
|
|
436
463
|
failure = finish.failure or LlmFailure(
|
|
437
464
|
message="model request failed", code="UNKNOWN"
|
|
438
465
|
)
|
|
439
466
|
raise LlmError(failure.message, failure.code, failure)
|
|
467
|
+
retries[by] += 1
|
|
468
|
+
attempt = retries.total()
|
|
469
|
+
# The loop's own record of the call it is about to make again,
|
|
470
|
+
# beside `step/start` for the first one (P1): the log's answer
|
|
471
|
+
# to "how many model calls", whichever row asked for the retry.
|
|
472
|
+
# `llm/retry` is `llm-retry`'s account of itself and a compaction
|
|
473
|
+
# retry writes none, so a ceiling counting those undercounts.
|
|
474
|
+
_LOG.append(
|
|
475
|
+
self.session,
|
|
476
|
+
"step/retry",
|
|
477
|
+
{"turn": turn, "step": step, "attempt": attempt, "by": by},
|
|
478
|
+
)
|
|
479
|
+
# **The wait is the loop's**, after the waterfall has settled: a
|
|
480
|
+
# row that slept inside it answered only after a later veto
|
|
481
|
+
# could no longer save the time, and a gate had to run *ahead*
|
|
482
|
+
# of it to refuse at all (P1 review).
|
|
483
|
+
if action.delay_ms > 0:
|
|
484
|
+
await anyio.sleep(action.delay_ms / 1000)
|
|
485
|
+
self._throw_if_canceled()
|
|
440
486
|
continue
|
|
441
487
|
|
|
442
488
|
blocks = assembler.blocks()
|
|
@@ -475,21 +521,38 @@ class ReactLoopAgent:
|
|
|
475
521
|
return TurnEndReason(kind="completed") if outcome.concluded else None
|
|
476
522
|
|
|
477
523
|
async def _request_error(
|
|
478
|
-
self,
|
|
479
|
-
|
|
524
|
+
self,
|
|
525
|
+
turn: int,
|
|
526
|
+
step: int,
|
|
527
|
+
retries: Mapping[str, int],
|
|
528
|
+
request: GenerateOptions,
|
|
529
|
+
finish: FinishReason,
|
|
530
|
+
) -> tuple[RequestErrorAction | None, str]:
|
|
531
|
+
"""The chain's answer, and the row whose listener gave it.
|
|
532
|
+
|
|
533
|
+
Attributed by cordis rather than claimed on the action (G13): the retry
|
|
534
|
+
is counted under the plugin that granted it, which a row could otherwise
|
|
535
|
+
misname and which cordis already knows.
|
|
536
|
+
"""
|
|
537
|
+
|
|
480
538
|
async def inner(request_failure: RequestFailure) -> RequestErrorAction | None:
|
|
481
539
|
return None
|
|
482
540
|
|
|
483
541
|
failure = RequestFailure(
|
|
484
542
|
agent=self,
|
|
543
|
+
session=self.session,
|
|
485
544
|
turn=turn,
|
|
486
545
|
step=step,
|
|
487
546
|
provider=request.provider,
|
|
488
547
|
failure=finish.failure or LlmFailure(message="model request failed", code="UNKNOWN"),
|
|
548
|
+
# A copy: the payload is a snapshot, and the loop goes on counting.
|
|
549
|
+
retries_by=dict(retries),
|
|
550
|
+
)
|
|
551
|
+
action, by = await self.ctx.waterfall_attributed(
|
|
552
|
+
"agent/request-error", failure, inner=inner
|
|
489
553
|
)
|
|
490
|
-
action = await self.ctx.waterfall("agent/request-error", failure, inner=inner)
|
|
491
554
|
self._throw_if_canceled()
|
|
492
|
-
return settled_or_none("agent/request-error", action, RequestErrorAction)
|
|
555
|
+
return settled_or_none("agent/request-error", action, RequestErrorAction), by
|
|
493
556
|
|
|
494
557
|
def _append_assistant_message(
|
|
495
558
|
self,
|
|
@@ -510,7 +573,9 @@ class ReactLoopAgent:
|
|
|
510
573
|
data["usage"] = usage.to_wire()
|
|
511
574
|
if interrupted:
|
|
512
575
|
data["interrupted"] = True
|
|
513
|
-
|
|
576
|
+
_LOG.append(
|
|
577
|
+
self.session, "assistant/message", data, SurfaceIntent("append", tuple(chunk_seqs))
|
|
578
|
+
)
|
|
514
579
|
|
|
515
580
|
async def _build_request(
|
|
516
581
|
self, turn: int, step: int, assembly: PromptAssembly, system: str
|
|
@@ -531,7 +596,9 @@ class ReactLoopAgent:
|
|
|
531
596
|
async def inner(proposal: RequestProposal) -> LlmCallConfig:
|
|
532
597
|
return proposal.config
|
|
533
598
|
|
|
534
|
-
proposal = RequestProposal(
|
|
599
|
+
proposal = RequestProposal(
|
|
600
|
+
agent=self, session=self.session, turn=turn, step=step, config=seed
|
|
601
|
+
)
|
|
535
602
|
answered = await self.ctx.waterfall("agent/request", proposal, inner=inner)
|
|
536
603
|
self._throw_if_canceled()
|
|
537
604
|
proposed = settled("agent/request", answered, LlmCallConfig)
|
|
@@ -546,20 +613,21 @@ class ReactLoopAgent:
|
|
|
546
613
|
)
|
|
547
614
|
baseline = session.request_header()
|
|
548
615
|
if not self._request_header_logged:
|
|
549
|
-
|
|
616
|
+
_LOG.append(
|
|
617
|
+
session,
|
|
550
618
|
"request/header",
|
|
551
619
|
{"header": header.to_wire(), "reason": "initial" if baseline is None else "resume"},
|
|
552
620
|
)
|
|
553
621
|
self._request_header_logged = True
|
|
554
622
|
elif baseline is None or not header_equals(baseline, header):
|
|
555
|
-
|
|
623
|
+
_LOG.append(session, "request/header", {"header": header.to_wire(), "reason": "change"})
|
|
556
624
|
|
|
557
625
|
resolved = self.ctx.require(LLM).resolve_model(proposed.provider, proposed.model)
|
|
558
626
|
request_context = RequestContext(
|
|
559
627
|
provider=proposed.provider, model=proposed.model, context_window=resolved.context_window
|
|
560
628
|
)
|
|
561
629
|
if session.request_context() != request_context:
|
|
562
|
-
|
|
630
|
+
_LOG.append(session, "request/context", request_context.to_wire())
|
|
563
631
|
self._throw_if_canceled()
|
|
564
632
|
|
|
565
633
|
return GenerateOptions(
|
|
@@ -609,6 +677,45 @@ def _error_chain(error: BaseException) -> str:
|
|
|
609
677
|
return ": ".join(parts)
|
|
610
678
|
|
|
611
679
|
|
|
680
|
+
def _turn_reason(ends: TurnEndReason | None, capped: bool) -> TurnEndReason:
|
|
681
|
+
"""What `turn/end` records, given how the loop left and what it passed through.
|
|
682
|
+
|
|
683
|
+
**A cap is not erased by finishing.** A step that hit `max_tokens` cut the
|
|
684
|
+
model off, and the turn may then have carried on — a steer, a tool call,
|
|
685
|
+
another step — and ended tidily. Reporting that as `completed` tells the
|
|
686
|
+
person their answer is whole when part of it was dropped, so `max-tokens`
|
|
687
|
+
outranks the two endings that mean "nothing went wrong".
|
|
688
|
+
|
|
689
|
+
It does not outrank the three that say more: `aborted`, `blocked` and
|
|
690
|
+
`error` each name something the reader has to act on, and a cap somewhere in
|
|
691
|
+
the turn is the smaller fact beside them.
|
|
692
|
+
|
|
693
|
+
**`None` means an exception left the body, so it is `aborted`** (C9). Every
|
|
694
|
+
normal exit from `_turn` assigns `ends` first — both `break`s are guarded by
|
|
695
|
+
`turn_ends is not None` and both `return` paths set it — so a `None` arriving
|
|
696
|
+
here cannot be a turn that finished. It used to read as `completed`, and each
|
|
697
|
+
`except` branch in `_turn` existed to write over that default before the
|
|
698
|
+
`finally` could believe it.
|
|
699
|
+
|
|
700
|
+
Deriving it here instead of catching it there is what makes the coverage
|
|
701
|
+
total. A branch for `anyio.get_cancelled_exc_class()` still misses the
|
|
702
|
+
`BaseExceptionGroup` an anyio task group raises around one, and
|
|
703
|
+
`KeyboardInterrupt`, and `SystemExit` — each of which would have recorded a
|
|
704
|
+
turn that finished nothing as *completed*, so the resume path saw no open
|
|
705
|
+
turn to repair and a reader was told a story the run did not have.
|
|
706
|
+
|
|
707
|
+
`kind="user"` is the honest cause for that case: something outside the turn
|
|
708
|
+
decided, and the loop cannot see what. The harness's own cancellations arrive
|
|
709
|
+
as `AgentCanceled`/`Canceled`, are caught in `_turn`, and carry their real
|
|
710
|
+
cause.
|
|
711
|
+
"""
|
|
712
|
+
if ends is None:
|
|
713
|
+
return TurnEndReason(kind="aborted", reason=AgentCancelCause(kind="user"))
|
|
714
|
+
if ends.kind == "completed":
|
|
715
|
+
return TurnEndReason(kind="max-tokens" if capped else "completed")
|
|
716
|
+
return ends
|
|
717
|
+
|
|
718
|
+
|
|
612
719
|
def _as_failure(error: Exception) -> LlmFailure:
|
|
613
720
|
"""A raised error as a turn's `reason.error`, keeping its code if it has one.
|
|
614
721
|
|