Compare commits

..
Author SHA1 Message Date
weichenandCursor dc47c10d68 Add agent_system_identity marker with ensure/verify CLI
Why: U1 I3 needs a DB-side instance identity anchor matching
manifest.instance_id so wrong/missing DB blocks startup (I4).
Evidence: go test ./internal/store/ ./internal/app/; CLI smoke
ensure/verify on SQLite temp dir.
Impact: migration v26; store Ensure/Verify; identity subcommand;
serve checks AGENT_SYSTEM_INSTANCE_ID when set.
Refs: docs/recovery/u1-identity-manifest.md §5.2; Dev feature/recovery-u1-db-identity

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 04:04:26 +00:00
weichen cb386023d0 Merge pull request 'Keep auto-present reminders synchronous to avoid run races' (#172) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Failing after 58s
2026-10-12 03:57:11 +00:00
weichenandCursor 9bd0aae9d8 Keep auto-present reminders synchronous to avoid run races
ci / vet-and-test (pull_request) Successful in 56s
Why: Async notice-run delivery raced with test teardown and in-flight
conversation runs (database closed / material lifecycle flakes).
Evidence: go test ./internal/runmgr/ -run Material
Impact: Reminders append as system messages only; material path still
inlines into startNoticeRun.
Refs: Dev PR #425 CI test-pg failure

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:56:59 +00:00
weichen b6ea1a5910 Merge pull request 'Drop runmgr import of genui to satisfy package boundaries' (#169) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Successful in 1m1s
2026-10-12 03:48:28 +00:00
weichenandCursor 41df7ae56f Drop runmgr import of genui to satisfy package boundaries
ci / vet-and-test (pull_request) Successful in 1m10s
Why: lint-boundaries forbids backend/runmgr -> backend/genui; keep the
auto trigger as an injected interface with local reminder text.
Evidence: bash scripts/lint-boundaries.sh
Impact: No behavior change; wiring still via app.SetGenUIAuto(genui.NewAuto).
Refs: architecture boundaries

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:48:10 +00:00
weichen 9b69117bea Merge pull request 'Add Generative UI auto-present opt-in with budget and cooldown' (#168) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Failing after 1m4s
2026-10-12 03:45:03 +00:00
weichenandCursor 6ff7fa9bd7 Add Generative UI auto-present opt-in with budget and cooldown
ci / vet-and-test (pull_request) Successful in 1m4s
Why: Known-boundary follow-up: owner opt-in auto page suggestions on
semantic task events without self-trigger from surface revisions.
Evidence: go test ./internal/genui/ ./internal/runmgr/ ./internal/settings/ ./internal/app/
Impact: Default-off settings key; material/plan/todo/document hooks nudge present.
Refs: docs/proposals/2026-10-12-generative-ui-auto-trigger.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:44:47 +00:00
weichen d1b5a94d15 Merge pull request 'Whitelist B-class surface actions for help and intent' (#167) from feature/genui-bclass-help-intent into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-12 03:23:57 +00:00
weichen ba619747c5 Merge authoritative main into the B-class branch
ci / vet-and-test (pull_request) Successful in 1m12s
2026-10-12 03:23:43 +00:00
weichenandCursor 5e0077dc11 Whitelist B-class surface actions for help and intent
ci / vet-and-test (pull_request) Successful in 48s
Why: Generative UI U5 left the B-class action allowlist empty; owner
authorized first entries builtin.help and system.intent.
Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/
Impact: Surface buttons may bind help/intent; button-entrance intent
matches only and does not nest-dispatch write commands.
Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:18:57 +00:00
weichen b160515905 Merge pull request 'Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages' (#166) from feature/genui-spec-repair into main
ci / vet-and-test (push) Successful in 55s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/166
2026-10-12 03:09:35 +00:00
weichen 9ec9f9a010 Merge pull request 'Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)' (#164) from feature/u7-load-fault-release into main
ci / vet-and-test (push) Successful in 59s
2026-10-12 03:09:03 +00:00
weichen 58598df095 Merge remote-tracking branch 'origin/main' into feature/u7-load-fault-release
ci / vet-and-test (pull_request) Successful in 51s
2026-10-12 03:07:33 +00:00
weichen 3d850a282f Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages
ci / vet-and-test (pull_request) Successful in 1m4s
Why: MiniMax-M3 emits XML-RPC-style {"item": ...} single-key wrappers on
array fields (kv/cells/items), bare scalar table cells, and page
submission delegated to the general sub-agent had no generative-ui
tool - observed live on dev (events seq 1395/1396/2171) where every
present with kv/table/list nodes failed invalid-spec and the delegated
retry reported the tool missing.

Evidence: red test reproducing the exact production error
(.nodes.1.kv of type [][2]string) goes green after the repair; full
genui/prompts/runtime/runmgr suites pass (SELF); mismatched-length
cells stay untouched by a dedicated guard test.

Impact: generative-ui pages with kv/table/list nodes save successfully
again; the general sub-agent can submit pages (tool resolves only when
the plugin exposes it, so absent plugins are unaffected).

Refs: dev task card feature/genui-spec-repair; dev conversation
激活验证-1012.
2026-10-12 03:03:26 +00:00
weichen a80d3c3359 Merge pull request 'Declare admin-zone sections from compiled plugins (surface 2.9.0, WP3-I3)' (#165) from feature/adminzone-plugin-sections into main
ci / vet-and-test (push) Successful in 46s
2026-10-12 02:39:56 +00:00
weichen 1719b5f668 Merge authoritative main into the admin-zone branch
ci / vet-and-test (pull_request) Successful in 49s
2026-10-12 02:37:57 +00:00
weichen 62bfc6068a Declare admin-zone sections from compiled plugins (surface 2.9.0)
ci / vet-and-test (pull_request) Successful in 46s
Why: the admin zone could not host plugin-declared partitions - KTD4
requires the RDM audit partition to be reachable by RDM domain admins and
base superadmins without hardcoding RDM into the base AdminZone.
Evidence: go test ./internal/app ./internal/server ./pkg/extension green
incl. new admin-zone recording/endpoint tests; surface snapshot test
green after surface.txt update; CI (Dev composite) is authoritative.
Impact: extension surface 2.8.0 -> 2.9.0 (Registry.AddAdminZone +
AdminZoneSpec, fail-closed on invalid specs); applyCompiled records
declarations into the plugin summary; new authenticated GET
/api/admin-zone/sections returns the flattened, ordered declaration list.
Entry visibility stays metadata-only - data capability remains enforced
by each section's own command surface (fail-closed).
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:28:51 +00:00
weichen 0a7413903d Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)
ci / vet-and-test (pull_request) Successful in 48s
Why: two-process HTTP acceptance (Dev tests/integration/multi-instance, added in
the matching Dev-side change) exposed three real cross-instance defects that the
U5 library-level tests bypassed via direct Manager calls, plus the U7 fault
matrix needs explicit disk-full rejection evidence.
Evidence: go test ./... green on SQLite and real PG (AGENT_PG_TEST_DSN);
new regressions TestCrossInstanceResolveDoesNotSettleCard (runmgr, PG-gated),
TestUploadRejectsCleanlyWhenObjectDirUnwritable and
TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable (server).
Impact: ResolveHITL no longer settles the card when the run executes on another
instance (command-bus fallback stays available); cancelRun enqueues the command
with the real conversation id and no longer reports a false local success;
object-store failures now provably leave zero DB/object residue.
Refs: collaboration storage proposal U7 (sections 3/5/7); task card
feature/u7-load-fault-release.
2026-10-12 02:28:48 +00:00
weichen 696ef15168 Merge pull request 'Add project-level surface aggregation and task-phase guidance (U6)' (#163) from feature/generative-ui-u6 into main
ci / vet-and-test (push) Successful in 53s
2026-10-12 02:12:59 +00:00
weichen add1fbb045 Add project-level surface aggregation and task-phase guidance (U6)
ci / vet-and-test (pull_request) Successful in 54s
Why: U6 extends pages from conversations into the task/project
workspace - surfaces aggregate across conversations under the owner's
project scope (project resolved authoritatively from the conversation
row, never self-reported by the model), and the catalog guidance now
organizes pages by task phase (requirements/execution/acceptance)
while stating that task facts come only from real task records.

Evidence: build/vet clean; surfacesvc suite green incl. the new
project-aggregation test (owner isolation, cross-owner zero, history
retained post-disable); genui/store suites green.

Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves
project from conversation; catalog text guidance. Existing endpoints
unchanged.

Refs: proposal r2 U6; Dev task feature/generative-ui-u6
2026-10-12 02:12:42 +00:00
weichen d835ad7466 Merge pull request 'List compiled plugins in /api/plugins (WP3-I4)' (#162) from feature/plugins-compiled-listing into main
ci / vet-and-test (push) Successful in 45s
2026-10-12 01:59:21 +00:00
weichen 9aa51e5250 List compiled plugins in /api/plugins
ci / vet-and-test (pull_request) Successful in 47s
Why: compiled-in plugins (generative-ui, rdm) never appeared in the admin
plugins list: GET /api/plugins only scanned the directory host, so binary-
level capabilities were invisible to operators (user-reported gap).
Evidence: go test ./internal/app/ ./internal/server/ green locally incl.
new TestApplyCompiledRecordsSummary and TestListPluginsMergesCompiled;
CI (make test) is authoritative.
Impact: applyCompiled records each plugin's registered commands/intents/
skills into a summary handed to the server; /api/plugins merges synthesized
inproc entries (backend.mode=inproc, apiVersion=extension surface version,
provides from the registration record). Directory entries win on id
collision. No public extension-surface change.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:44:56 +00:00
weichen e49f165d0b Merge pull request 'Material-intake guaranteed loop: deterministic spawn, choice chain, queue, cardflow on (Dev task feature/material-intake-flow)' (#161) from feature/material-intake-flow into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 17:47:56 +00:00
weichen 409390fff1 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Successful in 42s
2026-10-11 17:37:51 +00:00
weichen 4c637d4071 Merge pull request 'Repair object-shaped children maps into arrays in page specs' (#160) from fix/genui-children-repair into main
ci / vet-and-test (push) Successful in 49s
2026-10-11 16:57:51 +00:00
weichen 432b916cba Repair object-shaped children maps into arrays in page specs
ci / vet-and-test (pull_request) Successful in 52s
Why: Live E2E on the dev instance - the model keyed section children
as an object map (children.item.*), the strict decode rejected it, and
the page fell back to a file artifact; the guidance fix made the model
call present, this closes the remaining shape gap.

Evidence: genui suite green incl. the reproduction case; post-deploy
E2E rerun recorded in the Dev task.

Impact: Repair strategy only; validation unchanged.

Refs: Dev task fix/genui-page-visibility (E2E follow-up)
2026-10-11 16:57:48 +00:00
weichen 16ed7b281b Add background queue for system tasks and flip ui.cardflow default on
Why: at the 3-per-conversation cap the guaranteed material task was dropped silently, and long deliverables stayed in chat because cardflow defaulted off (user ruling 2026-10-11).
Evidence: new queue lifecycle test (queued card, hard-reject for model path, FIFO start on release, card promote) passes; full go test ./... exit 0 (local, SELF).
Impact: queueable system spawns create a queued progress card at capacity and start FIFO when a slot frees (memory-only queue; restart limitation noted); model delegate_task still hard-rejects; ui.cardflow default true (EffectNewRun).
Refs: Dev task .task/feature-material-intake-flow.md I3.
2026-10-11 16:56:59 +00:00
weichen 42867aabf4 Add deliverable-choice chain: confirm_choice tool, notice contract, backstops
Why: after material organization completes, weak models report and end the turn without offering the user a choice of formal deliverables, and even after a user picks one the model may close the turn without starting the background task.
Evidence: new tests (notice-choice chain, deliverable backstop, free-input path, invalid args, notice contract) pass; full go test ./... exit 0 (local, SELF).
Impact: new confirm_choice tool issues a HITL choice card and routes the answer into a hard delegate instruction; material notices instruct the model to report then offer choices; stop hook adds two one-shot backstops (choice not offered in material notice runs; choice made but no background delegation).
Refs: Dev task .task/feature-material-intake-flow.md I2.
2026-10-11 16:53:53 +00:00
weichen 02858f486b Add deterministic material-intake background path for attachment sends
Why: the dual-speed entry hint is advisory only; real runs (MiniMax, 3 attachments, no text) end with a clarifying question and zero delegation, so background cards and result backfill never start.
Evidence: new unit tests in runmgr/material_test.go and runtime/material_flow_test.go all pass; full go test ./... exit 0 (local, SELF).
Impact: attachment-carrying runs now guarantee a material-organization background task (pre-spawn on attachment-only/heavy sends, stop-hook backstop otherwise when the model did not delegate); subagent receives inline parsed text with budget plus knowledge_search guidance; content-key dedup prevents duplicate spawns (in-flight, completed, resend); model-authored delegate_task gains a materials appendix; settings runtime.materialIntake (default on) and runtime.materialIntakeChars added.
Refs: Dev task .task/feature-material-intake-flow.md I1; user ruling 2026-10-11.
2026-10-11 16:45:49 +00:00
weichen faae3d3901 Merge pull request 'Add model identity and in-database vector top-k via pgvector (U6 slice 3)' (#159) from feature/collab-storage-u6-vector into main
ci / vet-and-test (push) Successful in 10m52s
2026-10-11 16:31:25 +00:00
weichen 9b33df6625 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 10m28s
2026-10-11 16:31:15 +00:00
weichen 5d5cef9fcd Add model identity and in-database vector top-k via pgvector (U6 slice 3)
ci / vet-and-test (pull_request) Successful in 56s
Why: plan U6 vector slice (KTD11 correctness step) - same-dimension vectors from different models must never mix, and PG tiers should run filtered exact top-k in the database instead of scanning every row in-process.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 with pgvector (35 packages each tier) - pgvector_test covers model-identity and dimension isolation, update/delete atomicity (BLOB and vec_pg swap together), shared top-5 with score monotonicity, Recall@10 = exact-scan parity, deny-all predicate leakage; storage-migrate tests green after excluding the derived vec_pg column.

Impact: v25 migration adds model/content_hash columns and (PG, best-effort DO block) the pgvector extension plus vec_pg column - absence degrades to in-process scan; UpsertKnowledgeVectorExt dual-writes BLOB (cross-tier authority) and vec_pg; SearchKnowledgeVectorsModel/SharedModel filter by model identity and use SQL exact top-k (ORDER BY vec_pg <=> q) with automatic degradation; embedders expose Model() and the rag pipeline threads it through indexing and both retrieve paths; storage-migrate excludes vec_pg as a derived column.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 3, KTD11; R9.
2026-10-11 16:31:12 +00:00
weichen 1fa03a3cbb Merge pull request 'Add AdminProvider capability and HostProjectID context (EXT-18)' (#158) from feature/base-ext-admin-project into main
ci / vet-and-test (push) Successful in 54s
2026-10-11 16:23:31 +00:00
weichen 4ae125a3ef Add AdminProvider capability and HostProjectID context (EXT-18)
ci / vet-and-test (pull_request) Successful in 57s
Why: RDM KTD4 dual-layer admin requires base isAdmin visibility (superuser slot); KTD5 host-project binding needs trusted current-project context.

Evidence: SELF 4 ext18 tests pass + surface snapshot regenerated + full pkg/extension + internal/app suites green.

Impact: extension.Request gains HostProjectID; optional AdminProvider interface (EXT-17 pattern); inprocHost implements IsAdmin via GetUserAuthState; three dispatch sites set HostProjectID from conversation.

Refs: RDM docs/proposals U1 base extension requests
2026-10-11 16:23:15 +00:00
weichen 25fbea8522 Merge pull request 'Add same-transaction outbox, S3-compatible blobstore, and real-process acceptance (U5 remainder)' (#157) from feature/collab-storage-u5-outbox into main
ci / vet-and-test (push) Successful in 1m4s
2026-10-11 15:58:18 +00:00
weichen e1dc93d147 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Successful in 1m10s
2026-10-11 15:58:03 +00:00
weichen eb4796cf61 Add same-transaction outbox, S3-compatible blobstore, and real-process acceptance (U5 remainder)
ci / vet-and-test (pull_request) Successful in 46s
Why: plan U5 remainder (KTD5/KTD9 + exit criteria) - commands must commit atomically with business writes, artifacts need a shared object store for multi-instance, and the two-real-process acceptance matrix must run in CI.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 (35 packages each tier) - outbox tx test (rollback removes the command, commit makes both visible, idempotent re-enqueue), S3 store tests (SigV4-signed round trip, 404 sentinel, idempotent delete, presigned GET, env fallback), real-process acceptance (testbinary self-spawn of two serve processes with per-process identity and command consumers against one schema: same-conversation contention, cross-instance cancel command claimed exactly once, stale-owner settle fenced with run unchanged).

Impact: EnqueueCommandInTx for in-transaction command outbox and AppendEvents now notifies in the same transaction (KTD6 front half); blobstore.S3 (SigV4, presign, prefix isolation) injected from BLOBSTORE_S3_* env when configured, local fallback otherwise; realproc_test provides the process-level multi-instance acceptance gate (PG-env gated, runs in CI test-pg job).

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD5/KTD9, section 5 exit criteria; R4/R7/R10.
2026-10-11 15:57:12 +00:00
weichen 1df183b104 Merge pull request 'Guide the model to present pages for page-like deliverables' (#156) from fix/genui-page-visibility into main
ci / vet-and-test (push) Successful in 47s
2026-10-11 15:56:08 +00:00
weichen 820aa4432e Guide the model to present pages for page-like deliverables
ci / vet-and-test (pull_request) Successful in 47s
Why: Field evidence from the dev instance - the present tool was
projected and enabled but never called, because every resident prompt
module steers 'make me a report' toward file artifacts; the tool
description carried only the component catalog, not when to use it.

Evidence: genui suite green (catalog assertion extended implicitly by
existing shape test); end-to-end model behavior verified post-deploy
in the Dev task.

Impact: Tool description text only (self-gating: description appears
only when the activation gate projects the tool).

Refs: Dev task fix/genui-page-visibility
EOF
)
2026-10-11 15:44:33 +00:00
weichen eb12f102f2 Merge pull request 'Add the surface action pipeline: ledger, guards, dispatch, submit (U5)' (#155) from feature/generative-ui-u5 into main
ci / vet-and-test (push) Successful in 1m6s
2026-10-11 14:24:34 +00:00
weichen f5eb27df86 Add the surface action pipeline: ledger, guards, dispatch, submit (U5)
ci / vet-and-test (pull_request) Successful in 1m10s
Why: The approved U5 scope needs the action half of the loop: an
idempotent ledger (migration v24), a single-choke SubmitAction (plugin
activation, current-revision, action whitelist, params-hash
idempotency), dispatch through the command bus so DangerHigh lands on
the existing inline confirm card with no Confirmed passthrough (the
endpoint does not even bind the field), the button-only
generative-ui.submit storing form data in the plugin KV ledger, and
catalog discipline rejecting unknown action ids at present time.

Evidence: go build/vet clean; full go test ./... green; surfacesvc/
genui/store suites also green under AGENT_PG_TEST_DSN; red matrix
covers disabled/stale-revision/cross-owner/unknown-action/idempotent
same-and-different-params/danger-high-await-confirm and the endpoint
forged-confirmed case.

Impact: Additive migration v24 + new endpoint/command; existing
endpoints and command semantics untouched (B-class whitelist starts
empty per the approved scope).

Refs: U5 action-scope r2 (approved, Documents #71); UI #46/#47;
Frontend #56; Dev task feature/generative-ui-u5
2026-10-11 14:24:31 +00:00
weichen 44d45e1092 Merge pull request 'Re-pin agent-system-core to 89f3906 to match Dev core' (#154) from feature/core-repin-89f3906 into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-11 13:54:32 +00:00
weichen ec9d5f78fe Re-pin agent-system-core to 89f3906 to match Dev core
ci / vet-and-test (pull_request) Successful in 1m7s
Why: PR #152 pinned the bundled core back to 7a98a7e citing "Dev core is
at 7a98a7e", but Dev origin/main pins modules/core at 89f3906; the
misalignment breaks Dev check-pins for any tree that materializes
recorded pointers (exposed by a fresh task worktree running make test).

Evidence: go build ./... and internal/server + internal/store tests
green on the repinned tree.

Impact: restores modules/backend/deps/agent-system-core == Dev
modules/core (89f3906), unblocking Dev gitlink bumps.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:52:34 +00:00
weichen 51ad3ce115 Merge pull request 'Fall back to session cookie on stale Bearer key and report authVia' (#153) from feature/admin-auth-gate-fix into main
ci / vet-and-test (push) Successful in 47s
2026-10-11 13:48:25 +00:00
weichen 6681c3b47c Fall back to session cookie on stale Bearer key and report authVia
ci / vet-and-test (pull_request) Successful in 44s
Why: a browser holding a revoked API key in localStorage sent the Bearer
header on every request; key precedence 401'd all APIs despite a valid
login session, locking admins out of user management. /api/me also
reported mustChangePassword for key principals, who cannot change
passwords (session-owned), dead-ending the forced-change gate.

Evidence: new TestStaleKeySessionFallback covers fallback and authVia;
TestAuthMiddleware/TestAdminAccountFlow unchanged and green; full
go test ./... green.

Impact: invalid key falls back to a valid session cookie before 401
(cookie-less programmatic clients unaffected); /api/me gains
authVia ("key"|"session"|""), letting the frontend keep key holders
out of the change-password gate.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:52 +00:00
weichen 9a1c911e53 Merge pull request 'Pin agent-system-core to 7a98a7e (align with Dev core)' (#152) from feature/u5-core-pin into main
ci / vet-and-test (push) Failing after 0s
2026-10-11 12:30:58 +00:00
weichen bfeeefab94 Pin agent-system-core to 7a98a7e (align with Dev core)
ci / vet-and-test (pull_request) Failing after 1s
Why: Dev modules/core is at 7a98a7e7 (core PR #26); backend's internal pin lagged at 89f3906 after a parallel merge, failing Dev check-pins.

Evidence: build and store/runmgr suites green with the aligned pin; check-pins green after this merge.

Impact: gitlink bump only.

Refs: core PR #26/#28 lineage.
2026-10-11 12:30:49 +00:00
weichen 840757240b Merge pull request 'Add execution ownership leases with epoch fencing and persistent run commands (U5)' (#151) from feature/collab-storage-u5-ownership into main
ci / vet-and-test (push) Successful in 1m5s
2026-10-11 12:26:35 +00:00
weichen 9422bf9c28 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 1m11s
2026-10-11 12:26:21 +00:00
weichen 603f1c0fa4 Add execution ownership leases with epoch fencing and persistent run commands (U5)
ci / vet-and-test (pull_request) Successful in 57s
Why: plan U5/KTD4+KTD5 - before a second instance may serve writes, conversation execution must have a single valid owner (DB lease, epoch increments on takeover), stale owners must not settle (in-transaction fence check), and cancel/HITL must reach the executing instance via a persistent command inbox.

Evidence: full suite green twice-relevant on SQLite and real PostgreSQL 18.6 (35 packages each tier) - lease tests (claim/contention/re-claim/renew fencing/expiry takeover/monotonic epoch, command inbox idempotent enqueue, atomic concurrent claim) and multi-instance tests (same-conversation contention rejected, stale-owner settle fenced with run unchanged, cross-instance cancel consumed exactly once by the owner instance, non-owner does not consume).

Impact: v23 migration (conversation_executions + run_commands); ClaimConversation stacks a DB lease on PG tiers (15min, release on conversation release); settle path fenced (fenced runs emit run.fenced, no terminal write); StartCommandConsumer polls the inbox for commands on locally-owned executions (cancel routes to local cascade or DB-level close-out; HITL revalidates gate and rejects expired explicitly); server cancel/HITL fall back to command enqueue with 202 when the run is not local; app wires instance identity (host:addr on PG tiers) and starts the consumer.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD4/KTD5; R4/R10.
2026-10-11 12:25:31 +00:00
weichen 9f7b3c5405 Merge pull request 'Pin agent-system-core to 89f3906 (surface TS types and event union)' (#150) from feature/generative-ui-u4-coredep into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-11 12:22:45 +00:00
weichen fadce52dce Pin agent-system-core to 89f3906 (surface TS types and event union)
ci / vet-and-test (pull_request) Successful in 1m2s
Why: Core #27/#28 landed the surface snapshot types and the
surface.updated union entry; the backend dep copy must match Dev's
modules/core pin or check-pins fails.

Evidence: scripts/check-pins.sh + combined make test in the Dev
workspace after re-pinning.

Impact: Gitlink bump only.

Refs: Core PR #27/#28; Dev task feature/generative-ui-u4
2026-10-11 12:22:41 +00:00
weichen 6abf112765 Merge pull request 'Add the generative-ui plugin, capability gating, and surface projection' (#149) from feature/generative-ui-u3 into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 10:38:27 +00:00
weichen 367dc207b3 Add the generative-ui plugin, capability gating, and surface projection
ci / vet-and-test (pull_request) Successful in 42s
Why: Generative UI U3 - the agent needs a real submission path and the
next turn needs to see prior pages: a built-in in-process plugin
registers generative-ui.present (exposeAsTool) carrying the catalog
(prompt=validation single source) with a schema-repair strategy
measured against real MiniMax-M3 deviations; the command bus gains an
owner-scoped tool gate so a disabled plugin stops injecting its tool
(execution guard remains in surfacesvc); ctxasm projects saved surfaces
back into context so the model can revise existing structure; doc-refs
are authorized against the owner's real artifacts.

Evidence: unit suites green (genui guards/epoch/doc-ref, gate fail-closed,
projection content); full go test ./... green; cmdbus/ctxasm also green
under AGENT_PG_TEST_DSN; real-model closed loop (env-gated
TestRealModelSurfaceLoop) passes: task->spec->save->projection->revise
with revision 1->2 and structure change, CNY 0.08 total.

Impact: New internal/genui package; additive cmdbus/ctxasm/runmgr/
runtime hooks; no storage or contract changes (U2 layer untouched).

Refs: Documents proposal r2 U3; Core PR #25/#26; Backend #143; Dev task
feature/generative-ui-u3
2026-10-11 10:38:15 +00:00
weichen 2bfebe378c Merge pull request 'Pin agent-system-core to the node-id fields merge (align with Dev core)' (#148) from feature/u6-core-pin into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 09:33:28 +00:00
weichen f8db2225bf Pin agent-system-core to the node-id fields merge (align with Dev core)
ci / vet-and-test (pull_request) Successful in 41s
Why: Dev top-level modules/core advanced to 7a98a7e7 (core PR #26, optional node id/source TS contract fields); check-pins requires the backend internal pin to match.

Evidence: go build and store/pgbus suites green with the bumped pin; check-pins green in the Dev worktree after this merge.

Impact: gitlink bump only (additive optional contract fields).

Refs: core PR #26.
2026-10-11 09:33:19 +00:00
weichen 8c113a70b8 Merge pull request 'Catch up event streams by cursor with real reconnect and periodic rescue (U6 slice 1)' (#147) from feature/collab-storage-u6-events into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 09:30:30 +00:00
weichen fc75847580 Catch up event streams by cursor with real reconnect and periodic rescue (U6 slice 1)
ci / vet-and-test (pull_request) Successful in 43s
Why: plan section 2/KTD6 found the listener fetched only the last event of a batch (intermediate events invisible to live subscribers) and kept waiting on a dead connection after a drop (no actual reconnect).

Evidence: pgbus suite green twice against real PostgreSQL - batch of 64 all visible with continuous seq, 1200 events across pages, connection-kill reconnect recovers the outage window, no-notify events delivered by the periodic rescue; full backend suite green on both tiers.

Impact: Listener tracks per-conversation cursors (first-seen catch-up from 0 is harmless under SSE seq dedup), pages via EventsAfter to the notified watermark, rebuilds the connection with backoff and resumes known streams, and a rescue ticker covers lost notifications; Notifier contract unchanged.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 1, KTD6; R5.
2026-10-11 09:30:29 +00:00
weichen 9ee87dcf2d Merge pull request 'Pin agent-system-core to 7a98a7e (TS contract authority for node ids)' (#146) from feature/generative-ui-u2-coredep into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 08:46:50 +00:00
weichen b302446986 Merge remote-tracking branch 'origin/main' into feature/generative-ui-u2-coredep
ci / vet-and-test (pull_request) Successful in 40s
2026-10-11 08:46:43 +00:00
weichen 5191efd891 Pin agent-system-core to 7a98a7e (TS contract authority for node ids)
ci / vet-and-test (pull_request) Successful in 39s
Why: Core PR #26 made ts/types.ts carry the node id/source fields; the
backend dep copy must match Dev's modules/core pin or check-pins fails.

Evidence: scripts/check-pins.sh in the Dev workspace; combined make
test rerun after re-pinning.

Impact: Gitlink bump only.

Refs: Core PR #26; Dev task feature/generative-ui-u2
2026-10-11 08:46:19 +00:00
weichen a466838440 Merge pull request 'Cover the surface tables in storage-migrate copy list' (#145) from feature/storage-migrate-new-tables into main
ci / vet-and-test (push) Successful in 37s
2026-10-11 08:33:01 +00:00
weichen 1d5672e4fe Cover the surface tables in storage-migrate's copy list
ci / vet-and-test (pull_request) Successful in 40s
Why: the copy-order coverage guard caught that backend PR #143's new tables (surfaces, surface_revisions, plugin_activation) were absent from the migration list - they would have been silently skipped on real migrations.

Evidence: TestCopyOrderCoversAllFixtureTables failed on the merged main and passes with the addition; full migrator suite green on SQLite and real PostgreSQL.

Impact: copyOrder list only; ordered after their referenced owners/projects.

Refs: backend PR #143; Dev U4 task.
2026-10-11 08:32:45 +00:00
weichen e3b1e0310e Merge pull request 'Add the storage-migrate tool: inventory, backup, and identity-governed migration (U4)' (#144) from feature/collab-storage-u4-migrator into main
ci / vet-and-test (push) Failing after 39s
2026-10-11 08:30:58 +00:00
weichen 40be71a3f1 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 41s
2026-10-11 08:30:53 +00:00
weichen 093b86fe7e Add the storage-migrate tool: inventory, backup, and identity-governed migration (U4)
ci / vet-and-test (pull_request) Successful in 38s
Why: plan U4/R7 - the SQLite-to-PostgreSQL path needs a managed entry that never replays SQLite dumps as PG SQL, fixes migration identity against a frozen snapshot, refuses foreign or non-empty targets, verifies per-table counts and ordered checksums, and keeps secrets out of reports (KTD14).

Evidence: integration tests against real PostgreSQL green twice (end-to-end with bool/NULL/Unicode/BLOB/sequence assertions, idempotent rerun, interrupted-table resume, foreign-data and wrong-snapshot refusals, unknown-owner block); SQLite unit tests for inventory/backup/coverage; full backend suite green on both tiers.

Impact: new cmd/storage-migrate binary (make build now emits bin/storage-migrate); store gains a DB() accessor documented for ops tooling; no server or runtime behavior change.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U4 and section 6; docs/guides/team-storage.md section 10.
2026-10-11 08:30:01 +00:00
weichen d79514302f Merge pull request 'Add the Surface store, activation guard, and EXT-17 host capability' (#143) from feature/generative-ui-u2 into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 08:27:02 +00:00
weichen 70983e19fe Merge remote-tracking branch 'origin/main' into feature/generative-ui-u2
ci / vet-and-test (pull_request) Successful in 40s
2026-10-11 08:26:55 +00:00
weichen 75829fa442 Add the Surface store, activation guard, and EXT-17 host capability
ci / vet-and-test (pull_request) Successful in 40s
Why: Generative UI proposal U2 needs the host side of the contract:
owner-isolated surface storage with revision CAS, an execution-level
enable/disable ledger (default-off, epoch bumped on every transition so
in-flight submits after a disable are rejected), a single-choke-point
service that validates specs (strict decode + catalog rules) before
saving, event publish with pending recovery, the optional SurfaceHost
extension capability, and direct API endpoints that share the same
guard (no bypass by entrance).

Evidence: go build/vet clean; full go test ./... passes (SQLite);
surface store tests also pass under AGENT_PG_TEST_DSN (PostgreSQL);
surface.txt regenerated via the AST snapshot test.

Impact: Additive migration v22 (new tables only); existing endpoints and
plugin_grants semantics untouched; old consumers unaffected. U3+ (data
binding, actions, frontend) deliberately out of scope.

Refs: Documents proposal 2026-10-10-generative-ui-plugin.md (r2 U2);
Core PR #25; Dev task feature/generative-ui-u2
2026-10-11 08:26:24 +00:00
weichen c8d66ca3cd Merge pull request 'Make notice redelivery iterative instead of self-recursive' (#142) from feature/notice-delivery-recursion-fix into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 08:12:14 +00:00
weichen 1ef1fdeac9 Make notice redelivery iterative instead of self-recursive
ci / vet-and-test (pull_request) Successful in 42s
Why: deliverNotices re-invoked itself synchronously from a defer after a failed startNoticeRun requeued the batch - a persistently failing delivery (busy conversation, missing row, slow DB) recursed without bound and overflowed the stack (real-PG CI job 2352, internal/runmgr).

Evidence: regression test TestNoticeDeliveryFailureIsBounded (missing conversation row) hangs on the old code and passes with the fix; full backend suite green on SQLite and against real PostgreSQL 18.6 + Redis; clean-HEAD reproduction isolated an unrelated count=2 admin-login artifact.

Impact: failed batches now return after requeue (retried on the next enqueue or restart); notices arriving during delivery are picked up by a bounded loop tail instead of defer recursion; no behavior change on the success path.

Refs: Dev PR #333; plan R8 real-database gate.
2026-10-11 08:12:03 +00:00
weichen 05c3447ebb Merge pull request 'Add shared execution budgets and the action-authz matrix (U3 slice 2)' (#141) from feature/collab-storage-u3-budget-authz into main
ci / vet-and-test (push) Successful in 38s
2026-10-11 05:18:20 +00:00
weichen f84733d128 Add shared execution budgets and the action-authz matrix (U3 slice 2)
ci / vet-and-test (pull_request) Successful in 43s
Why: plan U3 remainder (R8/KTD13) - execution admission must be bounded and shared (leases in the database, parents yield while waiting so budgets cannot deadlock parent-child), and project authorization must distinguish read/edit/approve/execute/admin with default-deny for unconfigured actions.

Evidence: full suite green on SQLite and against real PostgreSQL 18.6 + Redis (isolated instance); new tests - admission_pg_test (enforcement, per-user, concurrent no-oversell), runmgr admission tests (saturated-budget sync spawn progresses, background children share the budget, bounded queue 429), server action-matrix tests (default deny, legacy adapter read-only, action-aware authoritative + fail-closed).

Impact: admission_leases (v21) with atomic lease accounting under an advisory lock; runtime.execBudgetGlobal/PerUser/execQueueMax settings (all zero = off, historical behavior preserved); conversation turns, background and sync subagents share the pool, parents yield around synchronous children and HITL waits; queue-full rejects 429 before writing; server gains ProjectAction matrix + SetProjectActionAuthorizer with legacy EXT-8 read fallback.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 R8/KTD13; docs/guides/team-storage.md section 9.
2026-10-11 05:18:12 +00:00
weichen 3def525902 Merge pull request 'Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)' (#140) from feature/collab-storage-u3-atomicity into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 04:38:02 +00:00
weichen 5cd67be982 Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)
ci / vet-and-test (pull_request) Successful in 41s
Why: the approved plan U3 (KTD3/KTD7/KTD10 plus minimal recovery isolation) requires concurrent submits to not silently overwrite, unknown-side-effect runs to never auto-resume, and the shared rate limiter to degrade conservatively instead of failing open.

Evidence: full backend suite green twice-relevant paths against real PostgreSQL 18.6 and Redis (isolated instance) and on SQLite; new tests - card_version_test (concurrent single winner), recovery_hold_test + recovery_holds_test (skip/release/fail-closed CRUD), ratelimit atomic-window and degraded-fallback tests.

Impact: AppendMessageWithRequest now allocates seq under the conversation advisory lock in one transaction; activity_cards gain version (v19) with SaveCardVersioned/GetCardVersion/CardVersion and cardsvc user mutations (status/pin/action/resolve-by-ref) return 409-mapped conflicts while system mirrors keep blind writes; recovery_holds (v20) with admin list/release endpoints and fail-closed resume skip; Redis limiter uses one Lua INCR+PEXPIRE script and falls back to a per-instance limit with a Degraded counter.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 KTD3/KTD7/KTD10; R3/R6/R7/R8.
2026-10-11 04:37:50 +00:00
weichen f480c5b520 Merge pull request 'Retry transient SQLITE_BUSY at migration Begin and race on a materialized db' (#139) from feature/collab-storage-pg-tier-fix2 into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 04:09:00 +00:00
weichen 6f20e6cc03 Retry transient SQLITE_BUSY at migration Begin and race on a materialized db
ci / vet-and-test (pull_request) Successful in 39s
Why: the connection-open phase (DSN pragmas incl. journal_mode on first open) can return SQLITE_BUSY immediately - the busy handler does not cover that phase - so four racing migrators failed in 0.03s on slow CI disks regardless of the 30s timeout (jobs 2293/2285).

Evidence: local -count=30 green (fast disk) vs CI deterministic failure at 0.03s (no timeout wait occurred); fix verified with -count=5 locally plus full store suite.

Impact: bounded retry (5 attempts, backoff) on Begin BUSY only - statements inside the IMMEDIATE transaction remain lock-protected; the racing test now materializes the file/WAL first, matching the KTD8 scenario (concurrent migrators on a provisioned database).

Refs: Dev PR #325; plan KTD8.
2026-10-11 04:08:50 +00:00
weichen efbd7b1d04 Merge pull request 'Raise the SQLite busy timeout to 30s for serialized migrators' (#138) from feature/collab-storage-pg-tier-fix1 into main
ci / vet-and-test (push) Failing after 40s
2026-10-11 03:42:55 +00:00
weichen 6f698d0ba5 Raise the SQLite busy timeout to 30s for serialized migrators
ci / vet-and-test (pull_request) Successful in 42s
Why: with _txlock=immediate the whole migration pass holds the write lock; four racing migrators on a slow CI disk exceeded the 5s busy timeout (job 2285: SQLITE_BUSY in TestMigrateRacingSQLite) although the local tier passes.

Evidence: go test -count=3 on the migration atomicity suite green locally; CI failure log cited in the Dev PR.

Impact: local SQLite tier only - lock contention waits up to 30s instead of erroring; no schema or API change.

Refs: Dev PR #325; plan KTD8.
2026-10-11 03:42:47 +00:00
weichen 2497508271 Merge pull request 'Add the team PG tier, atomic migrations and the connection budget (U2)' (#137) from feature/collab-storage-pg-tier into main
ci / vet-and-test (push) Successful in 41s
2026-10-11 03:11:12 +00:00
weichen 8633711b1d Add the team PG tier, atomic migrations and the connection budget (U2)
ci / vet-and-test (pull_request) Successful in 38s
Why: the approved collaboration plan U2 (KTD1/KTD8/KTD12) requires an explicit fail-closed team deployment tier, DDL-and-ledger-atomic migrations serialized across migrators, and a configurable PG connection budget with sanitized effective values.

Evidence: real PostgreSQL 18.6 + pgvector + Redis 8.10.2 local gate - full backend suite green twice with AGENT_PG_TEST_DSN/AGENT_REDIS_TEST_ADDR (isolated svc.sh instance collabtest:8897, redis 6399); SQLite suite green without env.

Impact: config tier+pool keys and validation; store PoolOptions + statement_timeout DSN param + single-transaction migration with PG advisory lock and SQLite _txlock=immediate + unknown-schema-version refusal; app tier guard fail-closed (team requires DSN+auth, no SQLite fallback); CLI flags and explain rows; tests for tier/pool parsing, atomicity fault-injection, racing migrators, old-schema upgrade; fixed pre-existing real-PG test defects (blind &search_path= appends in runmgr/pgbus helpers, pg_trgm per-schema install race, exhausted seq-conflict retries).

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U2, KTD1/KTD8/KTD12; R2/R3/R7.
2026-10-11 03:10:55 +00:00
weichen ea4651b1f8 Merge pull request 'Remove the empty -ws shell directory after each apply' (#136) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 02:20:47 +00:00
weichen bd4b3bca94 Remove the empty -ws shell directory after each apply
ci / vet-and-test (pull_request) Successful in 40s
Why: relaxAndRemove cleared the per-exec id directory but left the -ws
parent behind, so every conversation accumulated an empty husk - the
live probe caught it in the workspace listing.

Evidence: go test ./... green; TestOverlayCleansShellDir pins the
behavior with a real bwrap roundtrip.

Impact: One extra os.Remove of the (now empty) parent; failures are
best-effort as before.

Refs: Dev task feature/agent-s0-workspace-overlay (fix-up of #135).
2026-10-11 02:18:01 +00:00
weichen 0c34b3595e Merge pull request 'Add the S0 task workspace overlay: frozen base, private upper, CAS apply' (#135) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 41s
2026-10-11 02:14:36 +00:00
weichen 01fd3b6fd4 Add the S0 task workspace overlay: frozen base, private upper, CAS apply
ci / vet-and-test (pull_request) Successful in 43s
Why: The sandbox tier wrote the session directory directly, so a buggy
or runaway script could leave the workspace half-mutated and nothing
bounded what a timeout had already written. The plan's first landing
shape (section 4.4) swaps the session bind for a frozen-base overlay:
each exec freezes the session dir (hardlink clone + sha256 baseline),
runs on a private upper, and only a clean exit promotes the change set
back via CAS (baseline hash match -> atomic replace/remove; externally
changed targets double-store as *.conflict-* instead of clobbering).
Timeouts and kills discard the upper entirely - half-written state
never lands.

Evidence: go test ./... green including bwrap integration roundtrips
(TestSandboxWorkspaceOverlayRoundtrip: add/modify/delete through a real
overlayfs upper with whiteout handling and ws cleanup;
TestSandboxWorkspaceOverlayTimeoutDiscards: partial writes discarded on
timeout) and unit anchors for freeze manifest (.agentx excluded), CAS
add/modify, conflict double-store, and the change summary.

Impact: Opt-in via --exec-workspace overlay / AGENT_SYSTEM_EXEC_WORKSPACE
(only with --exec-backend sandbox; fail-closed on unknown modes). The
model sees a one-line change summary appended to the tool output, so
discards and conflict files are visible. Default behavior is unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md section
4.4; Dev task feature/agent-s0-workspace-overlay.
2026-10-11 02:04:05 +00:00
weichen eda90ed310 Merge pull request 'Log R4 reminder injection for observable evidence' (#134) from feature/r4-injection-log into main
ci / vet-and-test (push) Successful in 55s
2026-10-11 01:24:40 +00:00
weichen 5ebd1bb525 Log R4 reminder injection for observable evidence
ci / vet-and-test (pull_request) Successful in 56s
Why: Reminders are attached to the request copy and never reach the
session journal, so a transcript grep proves nothing about whether the
R4 trigger fired - the first verification attempt was invalid evidence.
The dual-speed nudge already logs for exactly this reason.

Evidence: go test ./internal/runtime green; the follow-up live probe
must show the [runtime] r4 escalation reminder injected line in the
instance log.

Impact: Two log lines, nothing else.

Refs: Dev task feature/agent-e4-escalation-ledger (third fix-up).
2026-10-11 01:22:49 +00:00
weichen 6647d4879c Merge pull request 'Switch the R4 failure trigger to a sliding window' (#133) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 42s
2026-10-10 18:44:09 +00:00
weichen 6709325c38 Switch the R4 failure trigger to a sliding window
ci / vet-and-test (pull_request) Successful in 40s
Why: The consecutive-failure counter never fired in the live suite (0
reminders despite 20 failures) because real failures interleave with
successes - each success reset the streak. The pattern to catch is
"spinning overall", not "N in a row".

Evidence: go test ./... green; the anchor test now covers scattered
failures (3 in the last 6 outcomes escalates, 2 does not, once per run);
the next live run must show the reminder actually appearing in the
transcript.

Impact: recentOutcomes ring (last 6) replaces consecToolFailures; the
zero-hit search trigger keeps its own threshold (2); message wording
matches the window semantics.

Refs: Dev task feature/agent-e4-escalation-ledger (second fix-up).
2026-10-10 18:43:03 +00:00
weichen ebec49db3d Merge pull request 'Restrict the source tag to material reads; never tag the model's own files' (#132) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 43s
2026-10-10 18:22:26 +00:00
weichen 03b4fdabaf Restrict the source tag to material reads; never tag the model's own files
ci / vet-and-test (pull_request) Successful in 42s
Why: The first E4 live comparison regressed badly (toolFailures 10->24,
toolCalls 63->166) and the instance transcript shows the loop: tagging
kernel read_file/grep results put a trailing [src_N] line on the
model's own workspace files, the model treated it as external
tampering, and re-read/re-edit cycles cascaded into the read-before-
write and file-changed guards.

Why: The ledger only ever needed SOURCES - searches always, and
workspace reads only under inputs/ (the upload material area). Grep
cannot distinguish materials from self-produced files, so it stays
untagged.

Evidence: go test ./... green; new anchor TestEvidenceLedger
OnlyTagsMaterialReads pins the inputs/-only rule; the re-run live
comparison follows on the Dev side.

Impact: evidenceTools loses read_file/grep; isEvidenceRead gates
workspace reads to inputs/; everything else (search tags, escalation
reminders, prompts) unchanged.

Refs: Dev task feature/agent-e4-escalation-ledger (fix-up of #131).
2026-10-10 18:11:24 +00:00
weichen d116eda0bc Merge pull request 'Add R4 escalation reminders and the structured source-id evidence ledger (E4)' (#131) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 17:50:21 +00:00
weichen 21b8461525 Add R4 escalation reminders and the structured source-id evidence ledger (E4)
ci / vet-and-test (pull_request) Successful in 43s
Why: The plan's E4 remainder under the MiniMax-only constraint: the
harness must detect the model going in circles and inject a
change-of-strategy instruction (R4 - the model cannot be trusted to
notice its own repeat-fail loop), and number sourcing needs addressable
evidence: tool results carry [src_N|tool] tags so "found in materials"
becomes "found in source src_N" for both the model and the fact-check
notice.

Why: Failure counting lives in one place (onEvent end, keyed by the
slot status) because tool execution is parallel; the ledger and tag
append are serialized by the same mutex, and escalation reminders fire
once per condition streak - following the dual-speed nudge finding that
MiniMax ignores vague reminders but follows concrete instructions.

Evidence: go test ./... green; new anchors TestEscalationOnConsecutive
ToolFailures, TestEscalationOnZeroHitSearches, TestEvidenceLedger
TagsSource, TestEvidenceLedgerSkipsNonEvidenceTools. Live before/
after comparison runs on the Dev side per ruling 6.

Impact: knowledge/web/tavily searches, fs_read_file and the workspace
read_file/grep results now end with a source tag and enter the run
ledger (2k-char head); two consecutive tool failures or two empty
search results inject a concrete change-of-strategy reminder;
base.deliverables v7 and agent-sub-research v3 explain the tags;
fact-check notice points at src results. Non-evidence tools and
failures are unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E4,
R4); Dev task feature/agent-e4-escalation-ledger.
2026-10-10 17:41:38 +00:00
weichen bd687d27d4 Merge pull request 'Add estimate-marking awareness and a second fact-check round (E3)' (#130) from feature/agent-e3-evidence-gate into main
ci / vet-and-test (push) Successful in 45s
2026-10-10 16:10:42 +00:00
weichen 06f5835211 Add estimate-marking awareness and a second fact-check round (E3)
ci / vet-and-test (pull_request) Successful in 41s
Why: The E1 live comparison was the only metric that got worse
(unverifiedFacts 7 -> 12): the gate gave one correction round and then
shipped bare numbers. The design (E3) calls for number sourcing with a
mechanically checkable marking escape: numbers labeled as
targets/assumptions are honest planning quantities, not fabricated
facts, so the gate must let them pass - and the model must be told that
marking is the sanctioned way out.

Why: The marking decision is clause-scoped, not a fixed window - a
window wrongly blessed "3,500" because a neighboring clause said
"target 85%" (caught by the existing test). The second round only
fires when the unverified set actually changed, because the
continuation budget is shared across all final gates and a repeat
block on identical tokens starves the downstream gates (also caught by
an existing test).

Evidence: go test ./... green; new anchors TestMarkedAsEstimate,
TestUnverifiedFactsMarksPass, TestFactCheckGateRounds; the live eval
gains an unverifiedUnmarkedFacts field sharing the same marking rule.

Impact: base.deliverables v6 adds the hard number-sourcing rule;
research/explore/general sub-agent profiles v+1 require citations for
key numbers; fact gate: marked-in-clause numbers pass, round 2
escalates to "last interception" with the marking instruction, then
passes through with the check line. Eval report keeps the strict
series and adds the E3-series field.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E3,
D4); Dev task feature/agent-e3-evidence-gate.
2026-10-10 16:08:19 +00:00
weichen 017b40f761 Merge pull request 'Bump the agent-squad pin (E1 chain)' (#129) from feature/bump-squad into main
ci / vet-and-test (push) Successful in 42s
2026-10-10 10:12:30 +00:00
weichen 288e4763a3 Merge authoritative main into the bump branch
ci / vet-and-test (pull_request) Successful in 40s
2026-10-10 10:11:07 +00:00
weichen 5714c739de Merge pull request 'Wire the dual-speed lane into the real run path: entry hint, escalating nudge, think-stripped cards' (#128) from feature/dual-speed-real-path into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 10:09:51 +00:00
weichen 7ef6ef86cd Bump the agent-squad pin for the E1 chain
ci / vet-and-test (pull_request) Successful in 42s
2026-10-10 10:09:01 +00:00
weichen c449a176f9 Restore the E1 agent-native kernel pin c348441 over the stale submodule state captured by the merge
ci / vet-and-test (pull_request) Successful in 43s
2026-10-10 10:06:55 +00:00
weichen 8355794e45 Merge remote-tracking branch 'origin/main' into feature/dual-speed-real-path
# Conflicts:
#	internal/runtime/llm_direct.go
#	internal/runtime/llm_test.go
2026-10-10 10:05:38 +00:00
weichen b3f885f215 Strip inline think blocks from subagent summaries and card bodies
ci / vet-and-test (pull_request) Successful in 43s
Why: the round-6 real-model dual-speed run showed MiniMax writing its
reasoning as inline <think> blocks; finishChild's partsText fed them into
the background notice turn and the inbox result card verbatim.
Evidence: new runmgr test covers closed-block stripping with body preserved
and conservative handling of unclosed blocks; go test ./... 32 pkgs green.
Impact: partsText removes closed <think>...</think> spans before summary,
notice, and card assembly; unclosed blocks are kept as-is.
Refs: Dev task feature/dual-speed-real-path.
2026-10-10 10:01:15 +00:00
weichen 3f2c7c7033 Nudge long-running turns onto the dual-speed path via the kernel reminder hook
Why: the dual-speed slow lane shipped as an observable capability plus soft
prompt guidance, but on the real (non-mock) path models routinely grind
through heavy document tasks synchronously for minutes with zero user-visible
output - the pattern only engages when the model happens to think of it.
Evidence: five new unit tests over an injected clock cover fire-after-threshold,
quiet-before-threshold, disabled-by-setting, and the replied/delegated guard
paths (reminders observed in the captured kernel requests); full go test
./... 32 pkgs green. Real-model run recorded in the Dev task card.
Impact: runDirect tracks run start, user-reply, and background-delegation
state; reminders() injects a one-shot dual-speed nudge (SR-B10) once elapsed
time crosses runtime.dualSpeedNudgeSeconds (default 75s, 0 disables) while
the user has neither a reply nor a background lane. New setting registered.
Refs: Dev task feature/dual-speed-real-path; SR-B10 (RDM requirements/01 V2.1).
2026-10-10 09:11:18 +00:00
weichen eef60ff0b9 Merge pull request 'Enable parallel tool execution, fix delegate truncation, sandbox deps layer (E1)' (#127) from feature/agent-e1-parallel-fixes into main
ci / vet-and-test (push) Successful in 46s
2026-10-10 09:04:57 +00:00
weichen 4df81ef1da Enable parallel tool execution, fix delegate truncation, sandbox deps layer
ci / vet-and-test (pull_request) Successful in 43s
Why: Real runs were fully serial: the direct path never set the kernel's
parallel tool mode, delegate_task handed the parent only the child's
160-char summary (the full text was dropped), MiniMax streamed its
reasoning inside content <think> blocks, and the EXT-16 sandbox hid
~/.local so python-docx/openpyxl/pptx could not even import. All four
were measured defects (F1/F2/F3/F5) in the delegation/routing proposal.

Why: Serial-only mutual exclusion is not enough for order-sensitive
tools - the card-stream test failed with out-of-order appends - so the
ordering moved into the kernel (SerialTools partition, Agent-Native
#44); process events pair by CallID (#42) and tool results write back
through the injected call context (#43).

Evidence: go test ./... green, including new regression anchors:
TestDelegateToolReturnsFullText, TestToolEventsPairByCallID,
TestToolExecModeAndSerialSet, TestOpenLLMClientReasoningSplitAuto,
TestSandboxArgvDepsLayer, TestExecEnvJoinsPythonPath.

Impact: Direct runs now execute independent same-turn calls
(searches, reads, delegate_task, review_findings) concurrently by
default (runtime.parallelTools=false restores sequential); card flow,
plans, workflows, exec primitives and file edits keep model-declared
order. delegate_task returns the capped full child text. MiniMax-*
models get reasoning_split=true so thinking streams via
reasoning_content. --exec-deps-dir/AGENT_SYSTEM_EXEC_DEPS_DIR mounts a
read-only dependency layer into the sandbox and merges PYTHONPATH.
Bumps deps/agent-native to c348441.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E1,
F1-F5); Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 09:02:10 +00:00
weichen c6912b63e7 Merge pull request 'Keep the final reply clean when checks continue or the verifier is skipped' (#126) from feature/final-reply-pollution-fix into main
ci / vet-and-test (push) Successful in 43s
2026-10-10 07:56:41 +00:00
weichen 18aa99cb95 Keep the final reply clean when checks continue or the verifier is skipped
ci / vet-and-test (pull_request) Successful in 1m1s
Why: real run run_b520bf2e3037 showed the user-facing reply being displaced by
internal bookkeeping - the fact-check correction round's closing text plus the
same-source verifier skip notice became the visible answer while the actual
summary was folded into the process timeline (frontend splits at the last
process part). Same tail pollution reproduced on every same-model run since.
Evidence: go test ./... -count=1 green (32 pkgs); new regression tests cover
the demotion of the pre-check summary and the skip notice becoming a check
part; three old-contract tests updated to assert notices land as process
checks instead of final text.
Impact: stop() fact-check branch now demotes the standing summary (aligned
with the gate and cap branches) and asks the model for a fresh full reply;
verifier errors record as process checks (pass-state for same-source skip,
fail-state for real errors) instead of StopDecision.Notice text; the cap
notice likewise becomes a failed check line.
Refs: Dev task feature/final-reply-pollution-fix; diagnosis in
.task/feature-dual-speed-tasks.md lineage and session analysis 2026-10-10.
2026-10-10 07:48:03 +00:00
weichen d2bb936784 Merge pull request 'Make background subagents observable as the slow lane of dual-speed tasks' (#125) from feature/dual-speed-tasks into main
ci / vet-and-test (push) Successful in 44s
2026-10-10 06:52:50 +00:00
weichen 43087a79f8 Make background subagents observable as the slow lane of dual-speed tasks
ci / vet-and-test (pull_request) Successful in 45s
Why: SR-B10 (RDM requirements/01 V2.1, ruling J-03) defines the task-level
dual-speed pattern: a fast lane gives immediate feedback while deep work runs
in background with queryable progress and results posted back to the inbox.
The base system had the background spawn/notice machinery but nothing the
user could see while it ran or collect afterwards, so heavy tasks just made
the user wait on a synchronous turn.
Evidence: go test ./... -count=1 green (32 pkgs); new runmgr tests cover the
card lifecycle on success (progress card -> done/ok with result summary and
artifact line, card.created/updated/status events) and on failure
(done/danger with the error), plus the cards==nil no-op path.
Impact: spawnBackground now registers an inbox progress card linked to the
child run and rewrites it with the result on completion; the delegate_task
description and base-method working prompt teach the fast-feedback-first
pattern; a #background mock demo exercises the whole path end to end.
Refs: RDM docs/requirements/01 UR-27/SR-B10; .tmp 03-V2 §任务快慢双速通用模式;
Dev task feature/dual-speed-tasks.
2026-10-10 06:40:00 +00:00
123 changed files with 14113 additions and 309 deletions
+96 -3
View File
@@ -12,6 +12,7 @@ import (
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
"agent-system-backend/internal/store"
)
var version = "0.1.0-dev"
@@ -30,6 +31,8 @@ func main() {
os.Exit(runConfig(os.Args[2:]))
case "user":
os.Exit(runUser(os.Args[2:]))
case "identity":
os.Exit(runIdentity(os.Args[2:]))
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
@@ -46,6 +49,14 @@ func main() {
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)")
pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)")
pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)")
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
fs.Parse(os.Args[1:])
cliSet := visited(fs)
@@ -53,17 +64,32 @@ func main() {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
if msgs := resolved.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintln(os.Stderr, "agent-system: tier guard:", msg)
}
os.Exit(1)
}
if err := app.Run(app.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace,
Tier: resolved.Tier,
PGPool: store.PoolOptions{
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
QueryTimeoutSec: resolved.PGQueryTimeoutSec,
},
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -82,6 +108,16 @@ func visited(fs *flag.FlagSet) map[string]bool {
name = "allowedHosts"
case "disable-exec-tools":
name = "disableExecTools"
case "pg-max-open":
name = "pgMaxOpen"
case "pg-max-idle":
name = "pgMaxIdle"
case "pg-conn-max-idle-sec":
name = "pgConnMaxIdleSec"
case "pg-conn-max-lifetime-sec":
name = "pgConnMaxLifetimeSec"
case "pg-query-timeout-sec":
name = "pgQueryTimeoutSec"
}
set[name] = true
})
@@ -108,12 +144,22 @@ func runConfig(args []string) int {
allowedHosts := fs.String("allowed-hosts", "", "")
noExec := fs.Bool("disable-exec-tools", false, "")
execBackend := fs.String("exec-backend", "", "")
execDepsDir := fs.String("exec-deps-dir", "", "")
execWorkspace := fs.String("exec-workspace", "", "")
tier := fs.String("tier", "", "")
pgMaxOpen := fs.Int("pg-max-open", 0, "")
pgMaxIdle := fs.Int("pg-max-idle", 0, "")
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "")
fs.Parse(args[1:])
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -239,3 +285,50 @@ func runUser(args []string) int {
return 2
}
}
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
// 与 serve --instance(runs 进程标识)无关。
func runIdentity(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system identity <ensure|verify> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("identity "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录(SQLite 路径 <data>/agent-system.db)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite)")
instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc-<group>-…)")
dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)")
fs.Parse(args[1:])
if *instanceID == "" {
fmt.Fprintln(os.Stderr, "identity: --instance-id 必填")
return 2
}
ref := *dbref
if ref == "" {
if *dsn != "" {
ref = "pg"
} else {
ref = "sqlite"
}
}
switch sub {
case "ensure":
if err := app.EnsureSystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity ensure:", err)
return 1
}
fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
case "verify":
if err := app.VerifySystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity verify:", err)
return 1
}
fmt.Printf("verified instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
default:
fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub)
return 2
}
}
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
func pgSchemaDSN(t *testing.T) (string, func()) {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖")
}
schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
cleanup := func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
}
return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup
}
func TestMigrateEndToEndAndIdempotent(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("首次迁移:%v", err)
}
// 幂等重跑(同身份):表级 done 对账,零重复行。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("幂等重跑:%v", err)
}
// 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、
// 复合主键事件序。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
db := st.DB()
var pinned, archived bool
if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived {
t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err)
}
var title, note string
if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, &note); err != nil || !strings.Contains(note, "注") {
t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err)
}
var vec []byte
if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 {
t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err)
}
var ended sql.NullString
if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid {
t.Fatalf("NULL 保留:ended=%v err=%v", ended, err)
}
var seqMax int
if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 {
t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err)
}
// 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
}
// 报告不含秘密值。
var report string
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
t.Fatalf("报告泄密:err=%v", err)
}
}
func TestMigrateResumeAfterInterrupt(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatal(err)
}
// 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending),
// 重跑只重做该表,终态校验仍全绿。
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatalf("断点续跑:%v", err)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n)
}
}
func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 非空目标(人工数据、无迁移身份):拒绝。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
st.Close()
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil {
t.Fatal(err)
}
err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"})
if err == nil || !strings.Contains(err.Error(), "非空目标") {
t.Fatalf("非空目标应拒绝:%v", err)
}
if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil {
t.Fatal(err)
}
// 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil {
t.Fatal(err)
}
other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"})
if err == nil || !strings.Contains(err.Error(), "快照不同") {
t.Fatalf("换快照应拒绝:%v", err)
}
// 其他 migration_id 已存在:拒绝。
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"})
if err == nil || !strings.Contains(err.Error(), "其他迁移记录") {
t.Fatalf("他身份应拒绝:%v", err)
}
}
func TestMigrateBlocksUnknownOwner(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 在夹具里造一个 users 之外的 owner 行(直接写源库)。
srcPath := fixture
st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写)
if err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil {
t.Fatal(err)
}
st.Close()
err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"})
if err == nil || !strings.Contains(err.Error(), "未知 owner") {
t.Fatalf("未知 owner 应阻塞:%v", err)
}
}
+855
View File
@@ -0,0 +1,855 @@
// storage-migrate:SQLite → PostgreSQL 受管迁移入口(协作存储方案 U4/R7)。
//
// 纪律(方案 §5 U4 / §6):
// - 只处理 Backend 库;不执行 SQLite dump 作为 PG SQL(按行复制 + 类型映射);
// - 迁移身份(migration_id + source_snapshot_id + source_schema_version)落
// 目标库 migration_control:仅同一冻结快照可续传;目标已含其他数据即拒绝;
// - 报告只含计数、校验和与归属检查结论——不含 DSN、密钥与行正文(KTD14)。
//
// 用法:
//
// storage-migrate inventory --source agent-system.db [--artifacts dir] [--out report.json]
// storage-migrate backup --source agent-system.db --out snapshot.db
// storage-migrate migrate --source snapshot.db --target "postgres://…" \
// --migration-id <名> [--artifacts dir] [--out report.json]
package main
import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // SQLite 驱动(源,只读打开)
"agent-system-backend/internal/store"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "inventory":
err = cmdInventory(os.Args[2:])
case "backup":
err = cmdBackup(os.Args[2:])
case "migrate":
err = cmdMigrate(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "storage-migrate:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "用法:storage-migrate <inventory|backup|migrate> [flags](-h 看各子命令 flags)")
}
// ---- 公共小件 ----
func openSourceRO(path string) (*sql.DB, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("source %s: %w", path, err)
}
// query_only:迁移源只读(冻结快照,任何写入都是缺陷)。
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=query_only(1)", path))
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("source %s: %w", path, err)
}
return db, nil
}
func fileSHA256(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
// ---- inventory:只读盘点(§6.1) ----
func cmdInventory(args []string) error {
fs := flag.NewFlagSet("inventory", flag.ExitOnError)
source := fs.String("source", "", "SQLite 库路径(活跃库或冻结快照)")
artifacts := fs.String("artifacts", "", "工件对象目录(可选;盘点文件清单与哈希)")
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
fs.Parse(args)
if *source == "" {
return fmt.Errorf("--source 必填")
}
db, err := openSourceRO(*source)
if err != nil {
return err
}
defer db.Close()
rep, err := inventoryReport(db, *source, *artifacts)
if err != nil {
return err
}
return writeReport(rep, *out)
}
func inventoryReport(db *sql.DB, source, artifactsDir string) (map[string]any, error) {
version, err := sourceSchemaVersion(db)
if err != nil {
return nil, err
}
var mode string
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
return nil, fmt.Errorf("journal_mode: %w", err)
}
var chk string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
return nil, fmt.Errorf("integrity_check: %v (%q)", err, chk)
}
tables, err := sqliteTableCounts(db)
if err != nil {
return nil, err
}
snap, size, err := fileSHA256(source)
if err != nil {
return nil, err
}
rep := map[string]any{
"command": "inventory", "source": source, "source_sha256": snap,
"source_bytes": size, "source_schema_version": version,
"journal_mode": mode, "integrity": chk, "table_counts": tables,
"generated_at": nowISO(),
}
if artifactsDir != "" {
manifest, err := artifactManifest(artifactsDir)
if err != nil {
return nil, err
}
rep["artifacts"] = manifest
}
return rep, nil
}
func sourceSchemaVersion(db *sql.DB) (int, error) {
var v int
err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&v)
if err != nil {
if strings.Contains(err.Error(), "no such table") {
return 0, fmt.Errorf("源库缺 schema_migrations(不是本工具管理的库):%w", err)
}
return 0, err
}
return v, nil
}
// sqliteTableCounts:仅盘点本工具认识的业务表(见 copyOrder)。
func sqliteTableCounts(db *sql.DB) (map[string]int64, error) {
out := map[string]int64{}
for _, t := range copyOrder {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "no such table") {
continue // 旧库可能没有新表
}
return nil, fmt.Errorf("count %s: %w", t, err)
}
out[t] = n
}
return out, nil
}
func artifactManifest(dir string) (map[string]any, error) {
type entry struct {
Name, SHA string
Size int64
}
var entries []entry
hAll := sha256.New()
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
sum, _, err := fileSHA256(p)
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, p)
entries = append(entries, entry{rel, sum, info.Size()})
fmt.Fprintf(hAll, "%s %d %s\n", rel, info.Size(), sum)
return nil
})
if err != nil {
return nil, fmt.Errorf("artifacts %s: %w", dir, err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
return map[string]any{
"dir": dir, "files": entries, "file_count": len(entries),
"manifest_sha256": hex.EncodeToString(hAll.Sum(nil)),
}, nil
}
// ---- backup:一致性快照(§6.2:VACUUM INTO + 可打开校验 + 计数对账) ----
func cmdBackup(args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
source := fs.String("source", "", "活跃 SQLite 库路径")
out := fs.String("out", "", "快照输出路径(不得与源同文件;已存在则拒绝)")
fs.Parse(args)
if *source == "" || *out == "" {
return fmt.Errorf("--source 与 --out 必填")
}
if same, _ := filepath.EvalSymlinks(*source); same != "" {
if abs1, _ := filepath.Abs(same); abs1 != "" {
if abs2, _ := filepath.Abs(*out); abs2 == abs1 {
return fmt.Errorf("快照输出不得与源同文件")
}
}
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("快照输出已存在(不覆盖):%s", *out)
}
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
return err
}
// VACUUM INTO 在线安全(含 WAL 内容);query_only 会禁写,需普通打开。
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)", *source))
if err != nil {
return err
}
defer db.Close()
if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil {
return fmt.Errorf("vacuum into: %w", err)
}
// 校验:快照可打开、完整性、逐表计数与源一致。
snap, err := openSourceRO(*out)
if err != nil {
return err
}
defer snap.Close()
var chk string
if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
return fmt.Errorf("快照完整性校验失败:%v (%q)", err, chk)
}
srcCounts, err := sqliteTableCounts(db)
if err != nil {
return err
}
snapCounts, err := sqliteTableCounts(snap)
if err != nil {
return err
}
for t, n := range srcCounts {
if snapCounts[t] != n {
return fmt.Errorf("快照计数不符:%s 源=%d 快照=%d", t, n, snapCounts[t])
}
}
sum, size, err := fileSHA256(*out)
if err != nil {
return err
}
fmt.Printf("backup ok: %s(%d 字节,sha256 %s…,%d 表计数一致)\n", *out, size, sum[:12], len(srcCounts))
return nil
}
// ---- migrate:受管迁移(§6.3 + U4) ----
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
var copyOrder = []string{
"projects", "users", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "command_audit", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
}
const controlDDL = `
CREATE TABLE IF NOT EXISTS migration_control (
migration_id TEXT PRIMARY KEY,
source_snapshot_id TEXT NOT NULL,
source_schema_version INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
verified_at TEXT,
report TEXT
);
CREATE TABLE IF NOT EXISTS migration_table_state (
migration_id TEXT NOT NULL,
table_name TEXT NOT NULL,
source_count INTEGER NOT NULL,
target_count INTEGER NOT NULL,
checksum TEXT NOT NULL,
state TEXT NOT NULL,
finished_at TEXT,
PRIMARY KEY (migration_id, table_name)
);`
func cmdMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
source := fs.String("source", "", "冻结快照路径(backup 子命令产物;不接受活跃库)")
target := fs.String("target", "", "目标 PostgreSQL DSN(须为空库或同身份续传库)")
migrationID := fs.String("migration-id", "", "迁移实例名(人工指定,报告与续传身份的一部分)")
artifacts := fs.String("artifacts", "", "工件对象目录(可选;仅盘点清单入报告,不复制)")
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
fs.Parse(args)
if *source == "" || *target == "" || *migrationID == "" {
return fmt.Errorf("--source、--target、--migration-id 必填")
}
src, err := openSourceRO(*source)
if err != nil {
return err
}
defer src.Close()
snapID, _, err := fileSHA256(*source)
if err != nil {
return err
}
srcVersion, err := sourceSchemaVersion(src)
if err != nil {
return err
}
// 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与
// 未知版本门禁),再落控制表。DSN 不回显、不入报告。
st, err := store.OpenPostgres(*target, filepath.Join(filepath.Dir(*source), "migrate-artifacts"))
if err != nil {
return fmt.Errorf("open target: %w", err)
}
defer st.Close()
db := st.DB()
if _, err := db.Exec(controlDDL); err != nil {
return fmt.Errorf("control tables: %w", err)
}
if err := guardIdentity(db, *migrationID, snapID, srcVersion); err != nil {
return err
}
if err := ensureControlRow(db, *migrationID, snapID, srcVersion); err != nil {
return err
}
rep := map[string]any{
"command": "migrate", "migration_id": *migrationID,
"source_snapshot_id": snapID, "source_schema_version": srcVersion,
"started_at": nowISO(),
}
var tables []map[string]any
for _, t := range copyOrder {
if !sqliteHasTable(src, t) {
continue // 旧库无此表
}
entry, err := migrateTable(db, src, *migrationID, t)
if err != nil {
return fmt.Errorf("table %s: %w", t, err)
}
tables = append(tables, entry)
}
// 未知 owner 检查(U4:阻塞,不静默)。'' 与 'local' 是单租户默认身份。
if err := checkOwners(db, src); err != nil {
return err
}
// 校验:目标逐表重算计数与校验和,全部一致才落 verified。
if err := verifyTables(db, *migrationID, tables); err != nil {
return err
}
var totalSrc, totalDst int64
for _, e := range tables {
totalSrc += e["source_count"].(int64)
totalDst += e["target_count"].(int64)
}
rep["tables"] = tables
rep["rows_total"] = totalSrc
rep["verified_at"] = nowISO()
if *artifacts != "" {
manifest, err := artifactManifest(*artifacts)
if err != nil {
return err
}
rep["artifacts"] = manifest
}
repJSON, _ := json.Marshal(rep)
if _, err := db.Exec(`UPDATE migration_control SET finished_at=$1, verified_at=$2, report=$3 WHERE migration_id=$4`,
nowISO(), rep["verified_at"], string(repJSON), *migrationID); err != nil {
return err
}
return writeReport(rep, *out)
}
// guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。
// - 存在其他 migration_id 的完成记录 → 拒绝;
// - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝;
// - 业务表已有数据而控制表为空(人工造过数据)→ 拒绝。
func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_control WHERE migration_id<>$1`, migrationID).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("目标库已有其他迁移记录(%d 条):拒绝覆盖,换空库或专项回迁方案", n)
}
var snap, started string
var sv int
err := db.QueryRow(`SELECT source_snapshot_id, source_schema_version, started_at FROM migration_control WHERE migration_id=$1`, migrationID).Scan(&snap, &sv, &started)
switch {
case err == sql.ErrNoRows:
// 新迁移:业务表必须为空。
busy, err := businessRows(db)
if err != nil {
return err
}
if busy > 0 {
return fmt.Errorf("目标库业务表已有 %d 行且无迁移身份:拒绝(非空目标不得作为迁移目标)", busy)
}
case err != nil:
return err
default:
if snap != snapID {
return fmt.Errorf("同 migration_id 但快照不同(库=%s… 本次=%s…):仅同一冻结快照可续传", snap[:12], snapID[:12])
}
_ = sv
_ = started
}
return nil
}
func businessRows(db *sql.DB) (int64, error) {
var total int64
for _, t := range copyOrder {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "does not exist") {
continue
}
return 0, err
}
total += n
}
return total, nil
}
func ensureControlRow(db *sql.DB, migrationID, snapID string, srcVersion int) error {
_, err := db.Exec(`INSERT INTO migration_control (migration_id, source_snapshot_id, source_schema_version, started_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT(migration_id) DO NOTHING`, migrationID, snapID, srcVersion, nowISO())
return err
}
func sqliteHasTable(db *sql.DB, name string) bool {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n); err != nil {
return false
}
return n > 0
}
// migrateTable:done 则对账(幂等重跑);pending 则按行复制(布尔映射 +
// 批量 INSERT + 序列推进)后落 done 与源侧校验和。
func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[string]any, error) {
cols, pk, err := targetColumns(db, table)
if err != nil {
return nil, err
}
var state string
var srcCount, dstCountStored int64
var srcChecksum string
err = db.QueryRow(`SELECT state, source_count, target_count, checksum FROM migration_table_state WHERE migration_id=$1 AND table_name=$2`,
migrationID, table).Scan(&state, &srcCount, &dstCountStored, &srcChecksum)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err == sql.ErrNoRows {
state = "pending"
}
// 源侧计数与校验和始终重算(冻结快照上必然稳定;差异=快照被换,拒绝)。
bools, err := boolColumns(db, table, cols)
if err != nil {
return nil, err
}
countNow, sumNow, err := sourceScan(src, table, cols, pk, bools)
if err != nil {
return nil, err
}
if state == "done" {
if countNow != srcCount || sumNow != srcChecksum {
return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12])
}
entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"}
return entry, nil
}
if err := copyRows(db, src, table, cols); err != nil {
return nil, err
}
dstCount, _, err := targetScan(db, table, cols, pk, bools)
if err != nil {
return nil, err
}
if dstCount != countNow {
return nil, fmt.Errorf("复制后计数不符:源=%d 目标=%d", countNow, dstCount)
}
if err := advanceSequences(db, table); err != nil {
return nil, err
}
if _, err := db.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`,
migrationID, table, countNow, dstCount, sumNow, "done", nowISO()); err != nil {
return nil, err
}
return map[string]any{"table": table, "source_count": countNow, "target_count": dstCount, "checksum": sumNow, "state": "done"}, nil
}
// targetColumns:目标侧列清单(按名排序)+ 主键列。布尔列集合同取自目标
// information_schema(类型映射以 PG schema 为准——SQLite 侧 0/1 → bool)。
func targetColumns(db *sql.DB, table string) (cols []string, pk []string, err error) {
// 生成列(PG GENERATED ALWAYS,如 knowledge.tsv)与库内向量副本列
// (vec_pg:BLOB 权威的派生表示,U6-3)不复制不校验:PG 从业务列
// 派生或由 Upsert 双写,源库(SQLite)没有该列。
rows, err := db.Query(`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1 AND is_generated='NEVER'
AND column_name NOT IN ('vec_pg') ORDER BY column_name`, table)
if err != nil {
return nil, nil, err
}
defer rows.Close()
exists := false
for rows.Next() {
var name, dtype string
if err := rows.Scan(&name, &dtype); err != nil {
return nil, nil, err
}
exists = true
cols = append(cols, name)
}
if !exists {
return nil, nil, fmt.Errorf("目标缺表 %s(迁移器未建?)", table)
}
prows, err := db.Query(`
SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
WHERE i.indisprimary AND i.indrelid=$1::regclass`, table)
if err != nil {
return nil, nil, err
}
defer prows.Close()
for prows.Next() {
var p string
if err := prows.Scan(&p); err != nil {
return nil, nil, err
}
pk = append(pk, p)
}
if len(pk) == 0 {
pk = cols // 无主键表按全列序校验
}
return cols, pk, nil
}
func boolColumns(db *sql.DB, table string, cols []string) (map[string]bool, error) {
out := map[string]bool{}
rows, err := db.Query(`
SELECT column_name FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1 AND data_type='boolean'`, table)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out[c] = true
}
return out, nil
}
// canonicalRow:稳定行编码(校验和用):列名排序、值规范化
// (bool→true/false、[]byte→base64、NULL→null、数值/字符串原样)。
func canonicalRow(cols []string, vals []any, bools map[string]bool) string {
type kv struct {
K string
V any
}
pairs := make([]kv, len(cols))
for i := range cols {
pairs[i] = kv{cols[i], normalize(vals[i], bools[cols[i]])}
}
b, _ := json.Marshal(pairs)
return string(b)
}
// normalize:布尔列按目标 schema 对称归一(源 int64 0/1、目标 bool 编码一致,
// 否则校验和必然不符——真库实测);[]byte→base64。
func normalize(v any, isBool bool) any {
if isBool {
return sqliteBool(v)
}
switch t := v.(type) {
case []byte:
return base64.StdEncoding.EncodeToString(t)
default:
return v
}
}
// hashChain:按主键序哈希链(前后衔接,顺序敏感)。
func hashChain(prev string, rowJSON string) string {
h := sha256.Sum256([]byte(prev + "|" + rowJSON))
return hex.EncodeToString(h[:])
}
// scanChecksum:源/目标共用的扫描对账(列清单一致、主键序、行哈希链)。
func scanChecksum(rows *sql.Rows, cols []string, bools map[string]bool) (int64, string, error) {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
var count int64
sum := ""
for rows.Next() {
if err := rows.Scan(ptrs...); err != nil {
return 0, "", err
}
sum = hashChain(sum, canonicalRow(cols, vals, bools))
count++
}
return count, sum, rows.Err()
}
func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
orderBy := "ORDER BY " + quoteJoin(pk, `"`)
rows, err := src.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ` + orderBy)
if err != nil {
return 0, "", err
}
defer rows.Close()
return scanChecksum(rows, cols, bools)
}
func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`))
if err != nil {
return 0, "", err
}
defer rows.Close()
return scanChecksum(rows, cols, bools)
}
func quoteJoin(names []string, q string) string {
out := make([]string, len(names))
for i, n := range names {
out[i] = q + n + q
}
return strings.Join(out, ",")
}
// copyRows:按行复制。布尔列 0/1→false/true;其余原样(TEXT/INT/REAL/BLOB)。
func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
bools, err := boolColumns(db, table, cols)
if err != nil {
return err
}
colList := quoteJoin(cols, `"`)
ph := make([]string, len(cols))
for i := range ph {
ph[i] = fmt.Sprintf("$%d", i+1)
}
placeholders := strings.Join(ph, ",")
srcRows, err := src.Query(`SELECT ` + colList + ` FROM "` + table + `"`)
if err != nil {
return err
}
defer srcRows.Close()
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
if err != nil {
return err
}
defer stmt.Close()
var batch int
for srcRows.Next() {
if err := srcRows.Scan(ptrs...); err != nil {
return err
}
args := make([]any, len(cols))
for i, c := range cols {
v := vals[i]
if bools[c] {
args[i] = sqliteBool(v)
} else {
args[i] = v
}
}
if _, err := stmt.Exec(args...); err != nil {
return fmt.Errorf("insert: %w", err)
}
batch++
}
if err := srcRows.Err(); err != nil {
return err
}
return tx.Commit()
}
func sqliteBool(v any) bool {
switch t := v.(type) {
case int64:
return t != 0
case bool:
return t
default:
return false
}
}
// advanceSequences:SERIAL/identity 列复制后推进序列(显式插入不推进,
// 后续应用插入会撞主键)。无序列表为空操作。
func advanceSequences(db *sql.DB, table string) error {
rows, err := db.Query(`
SELECT pg_get_serial_sequence($1, column_name)
FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1
AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table)
if err != nil {
return err
}
defer rows.Close()
var seqs []string
for rows.Next() {
var s any
if err := rows.Scan(&s); err != nil {
return err
}
if s != nil {
seqs = append(seqs, s.(string))
}
}
if err := rows.Err(); err != nil {
return err
}
for _, s := range seqs {
if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil {
return fmt.Errorf("setval %s: %w", s, err)
}
}
return nil
}
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
}
rows, err := src.Query(`SELECT DISTINCT owner FROM "` + t + `" WHERE owner<>'' AND owner<>'local'`)
if err != nil {
return err
}
var unknown []string
for rows.Next() {
var o string
if err := rows.Scan(&o); err != nil {
rows.Close()
return err
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE owner=$1`, o).Scan(&n); err != nil {
rows.Close()
return err
}
if n == 0 {
unknown = append(unknown, o)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(unknown) > 0 {
return fmt.Errorf("表 %s 引用未知 owner(users 无此行):%v——先在目标补建用户或修正归属,再迁移", t, unknown)
}
}
return nil
}
// verifyTables:目标逐表重算,与登记的源计数/校验和全部一致才通过。
func verifyTables(db *sql.DB, migrationID string, tables []map[string]any) error {
for _, e := range tables {
table := e["table"].(string)
wantCount := e["source_count"].(int64)
wantSum := e["checksum"].(string)
cols, pk, err := targetColumns(db, table)
if err != nil {
return err
}
bools, err := boolColumns(db, table, cols)
if err != nil {
return err
}
gotCount, gotSum, err := targetScan(db, table, cols, pk, bools)
if err != nil {
return err
}
if gotCount != wantCount || gotSum != wantSum {
return fmt.Errorf("表 %s 校验不符:count %d/%d checksum %s…/%s…", table, gotCount, wantCount, gotSum[:12], wantSum[:12])
}
}
return nil
}
func writeReport(rep map[string]any, out string) error {
b, err := json.MarshalIndent(rep, "", " ")
if err != nil {
return err
}
if out == "" {
fmt.Println(string(b))
return nil
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
return os.WriteFile(out, append(b, '\n'), 0o644)
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// U4 迁移工具测试:夹具覆盖全部当前 schema 的边值(布尔/空值/时间/JSON/
// Unicode/二进制向量/秘密字段),以及 inventory/backup 的行为面。
// 真库端到端(幂等/续传/非空拒/未知 owner)在 integration_test.go(PG 门控)。
// buildFixture:建一个全 schema 的 SQLite 库并填充边值行,返回路径。
func buildFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "fixture.db")
st, err := store.OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
seedEdgeRows(t, st)
return path
}
func seedEdgeRows(t *testing.T, st *store.Store) {
t.Helper()
// users(含 is_admin/must_change 布尔)与单租户默认身份。
mustExec(t, st, `INSERT INTO users (id, owner, name, created_at, password_hash, password_set_at, must_change_password, is_admin) VALUES
('u1', 'alice', '爱丽丝', '2026-10-11T01:00:00Z', 'sha256:…', '2026-10-11T01:00:00Z', false, true),
('u2', 'bob', 'Bob 😊', '2026-10-11T01:00:00Z', '', '', true, false)`)
// 项目与会话(布尔 pinned/archived/allow_cross_read/cross_private、空 JSON)。
mustExec(t, st, `INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES
('p1', '项目一', '{}', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
('p2', 'Emoji 项目 🚀', '{"a":null,"b":[1,2,"三"]}', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
mustExec(t, st, `INSERT INTO conversations (id, project_id, title, profile_id, created_at, updated_at, owner, pinned, archived, allow_cross_read, cross_private, permission_mode, session_note) VALUES
('c1', 'p1', '会话', '', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice', true, false, false, true, '', 'note—注'),
('c2', 'p2', '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '', false, true, true, false, 'strict', '')`)
// 消息(request_id 幂等、parts JSON 含空对象/空数组/Unicode)与 seq 顺序。
mustExec(t, st, `INSERT INTO messages (id, conversation_id, run_id, role, parts, seq, created_at, request_id) VALUES
('m1', 'c1', '', 'user', '[{"type":"text","text":"你好"}]', 1, '2026-10-11T01:00:01Z', 'req-1'),
('m2', 'c1', '', 'assistant', '[{"type":"kv","kv":[]},{"type":"text","text":""}]', 2, '2026-10-11T01:00:02Z', ''),
('m3', 'c2', '', 'system', '[]', 1, '2026-10-11T02:00:01Z', 'req-2')`)
// runs(NULL ended_at/usage/error)。
mustExec(t, st, `INSERT INTO runs (id, parent_run_id, conversation_id, project_id, owner, kind, profile_id, status, phase, agent_name, created_at, instance_id) VALUES
('r1', '', 'c1', 'p1', 'alice', 'conversation', '', 'completed', '', '', '2026-10-11T01:00:00Z', 'host:1'),
('r2', 'r1', 'c1', 'p1', 'alice', 'subagent', '', 'running', 'thinking', '检索', '2026-10-11T01:00:03Z', 'host:2')`)
// events(复合主键 (conversation_id, seq))。
mustExec(t, st, `INSERT INTO events (conversation_id, seq, run_id, ts, v, type, payload) VALUES
('c1', 1, 'r1', '2026-10-11T01:00:01Z', 1, 'run.phase', '{"phase":"thinking"}'),
('c1', 2, 'r1', '2026-10-11T01:00:02Z', 1, 'message.appended', NULL),
('c2', 1, '', '2026-10-11T02:00:01Z', 1, 'x', '{}')`)
// 工件(size 0 与大值)。
mustExec(t, st, `INSERT INTO artifacts (id, project_id, name, kind, mime, size, source_run, content_path, created_at, updated_at, owner) VALUES
('a1', 'p1', '报告.md', 'doc', 'text/markdown', 0, 'r1', 'art/a1', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
('a2', 'p2', '数据.xlsx', 'doc', '', 2147483647, '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
// HITL 卡(resolved=0、resolution NULL)。
mustExec(t, st, `INSERT INTO hitl_cards (id, run_id, conversation_id, payload, created_at, resolved, resolution, owner) VALUES
('h1', 'r2', 'c1', '{"q":"继续?"}', '2026-10-11T01:00:04Z', 0, NULL, 'alice')`)
// 知识 + 向量(BLOB 二进制:float32 little-endian)。
mustExec(t, st, `INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k1', 'p1', 'upload', '标题', 'doc.md', '正文内容…', '2026-10-11T01:00:00Z', 'alice')`)
mustExec(t, st, `INSERT INTO knowledge_vectors (doc_id, owner, ref, dim, vec, created_at) VALUES
('k1', 'alice', 'doc.md', 3, x'0000803F0000003F000080BF', '2026-10-11T01:00:00Z')`)
// 活动卡(pinned 布尔)。
mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES
('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`)
// 命令审计(SERIAL 主键:显式 id 插入)。
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`)
// 设置('' 默认租户 scope)。
mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`)
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
mustExec(t, st, `INSERT INTO secret_keys (id, owner, service, label, value, active, created_at) VALUES
('sk1', 'alice', 'openai', 'main', 'sk-secret-value-不打印', 1, '2026-10-11T01:00:00Z')`)
// 恢复隔离(业务态,应随迁)。
mustExec(t, st, `INSERT INTO recovery_holds (run_id, owner, conversation_id, reason, created_at) VALUES
('rh1', 'alice', 'c1', '副作用未知', '2026-10-11T01:00:00Z')`)
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// mustExec:夹具直接走底层连接写行(业务代码不得如此;见 store.DB 注释)。
func mustExec(t *testing.T, st *store.Store, q string) {
t.Helper()
if _, err := st.DB().Exec(q); err != nil {
t.Fatal(err)
}
}
// ---- inventory ----
func TestInventoryReport(t *testing.T) {
path := buildFixture(t)
db, err := openSourceRO(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
rep, err := inventoryReport(db, path, "")
if err != nil {
t.Fatal(err)
}
if rep["source_schema_version"].(int) < 20 {
t.Fatalf("schema version: %v", rep["source_schema_version"])
}
counts := rep["table_counts"].(map[string]int64)
if counts["messages"] != 3 || counts["events"] != 3 || counts["users"] != 2 {
t.Fatalf("counts: %v", counts)
}
b, _ := json.Marshal(rep)
if strings.Contains(string(b), "sk-secret-value") {
t.Fatal("inventory 报告不得包含秘密值")
}
}
func TestInventoryMissingFile(t *testing.T) {
if _, err := openSourceRO(filepath.Join(t.TempDir(), "nope.db")); err == nil {
t.Fatal("缺文件应报错")
}
}
// ---- backup ----
func TestBackupVerifiesAndSnapshotsConsistent(t *testing.T) {
path := buildFixture(t)
out := filepath.Join(t.TempDir(), "snap.db")
if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil {
t.Fatal(err)
}
snap, err := openSourceRO(out)
if err != nil {
t.Fatal(err)
}
defer snap.Close()
var n int
if err := snap.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("snap messages: %d %v", n, err)
}
// 已存在的输出拒绝覆盖。
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil {
t.Fatal("已存在快照应拒绝")
}
}
// ---- 夹具即文档:确认全部业务表在 copyOrder 内(防新表漏迁) ----
func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
path := buildFixture(t)
db, err := openSourceRO(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
known := map[string]bool{}
for _, t := range copyOrder {
known[t] = true
}
known["schema_migrations"] = true // 迁移器管理,不随迁
// 运行态表不随迁:执行许可是进程瞬态;执行租约与跨实例命令属当前
// 实例的调度现场(接管语义在 U5 内由 lease/fencing 处理,不复制历史)。
for _, rt := range []string{"admission_leases", "conversation_executions", "run_commands"} {
known[rt] = true
}
// FTS5 虚表影子表:SQLite 内部结构,PG 侧由 v4 生成列/索引从 knowledge
// 行自动重建,不随迁。
for _, shadow := range []string{"knowledge_fts", "knowledge_fts_data", "knowledge_fts_idx", "knowledge_fts_docsize", "knowledge_fts_config"} {
known[shadow] = true
}
var missing []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
if !known[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("copyOrder 漏表(新表加入 schema 后须同步工具清单):%v", missing)
}
}
+17
View File
@@ -17,6 +17,8 @@ import (
"strings"
"testing"
"time"
"agent-system-backend/internal/runtime"
)
// upload 上传一个材料文件,返回 artifact id。
@@ -377,6 +379,18 @@ func unverifiedFacts(artifactText, materialText string) []string {
return out
}
// unmarkedFacts 从未核实清单里剔除已按「目标/预计/假设」标注的(与后端
// runtime.MarkedAsEstimate 同一口径,标注词邻域 ±32 字符)。
func unmarkedFacts(tokens []string, artifactText string) []string {
var out []string
for _, tok := range tokens {
if !runtime.MarkedAsEstimate(artifactText, tok) {
out = append(out, tok)
}
}
return out
}
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
func finalVsArtifact(turn *turnResult, artifactText string) []string {
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
@@ -430,6 +444,9 @@ func renderMarkdown(rep *report) string {
}
if len(tk.UnverifiedFacts) > 0 {
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
if len(tk.UnverifiedUnmarkedFacts) > 0 {
fmt.Fprintf(&b, "其中未标注(无出处且无目标/预计/假设标注,E3 闸门口径):%s\n", strings.Join(tk.UnverifiedUnmarkedFacts, "、"))
}
}
if len(tk.FinalVsArtifact) > 0 {
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
+4
View File
@@ -252,6 +252,9 @@ type taskResult struct {
Turns []turnResult `json:"turns"`
Total turnMetrics `json:"total"`
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
// UnverifiedUnmarkedFacts:无出处且未按「目标/预计/假设」标注的子集
// (E3 口径:严格口径 unverifiedFacts 保留原义以维持历史可比性)。
UnverifiedUnmarkedFacts []string `json:"unverifiedUnmarkedFacts,omitempty"`
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
}
@@ -307,6 +310,7 @@ func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir st
artText += downloadArtifactText(t, c, projectID, name)
}
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
tr.UnverifiedUnmarkedFacts = unmarkedFacts(tr.UnverifiedFacts, artText)
tr.FinalVsArtifact = finalVsArtifact(last, artText)
}
}
+206 -6
View File
@@ -30,10 +30,12 @@ import (
"agent-native/runtime/telemetry"
agenttools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/blobstore"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/config"
"agent-system-backend/internal/genui"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
@@ -50,6 +52,7 @@ import (
"agent-system-backend/internal/skills"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
@@ -79,6 +82,65 @@ type Options struct {
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
// 硬关闭 DisableExecTools 优先级高于后端选择。
ExecBackend string
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
ExecDepsDir string
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
ExecWorkspace string
// Tier 部署档(协作存储 KTD1):空=auto(DSN→PG 否则 SQLite);local=SQLite;
// team=PG fail-closed(缺 DSN/未认证拒绝启动;PG 故障拒绝就绪,不回落 SQLite)。
Tier string
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
PGPool store.PoolOptions
}
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
// 本地档退化为 local(单实例语义)。
func instanceTag(opt Options) string {
if opt.Tier == "team" || opt.StorageDSN != "" {
return hostTag() + ":" + opt.Addr
}
return "local"
}
func hostTag() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "host"
}
return h
}
// tierName 输出部署档名(诊断统一拼写:空 = auto)。
func tierName(opt Options) string {
if opt.Tier == "" {
return "auto"
}
return opt.Tier
}
// TierRefusal 部署档门禁(KTD1):返回阻止启动的问题(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证拒绝;PG 故障的拒绝发生在其后的
// OpenPostgres(Run 对 team 档的错误显式声明不回落 SQLite)。
func TierRefusal(opt Options) []string {
return config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, Tier: opt.Tier,
PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
}}.TierProblems()
}
// latestVersion 返回已应用迁移的最高版本(0 = 空账本)。
func latestVersion(versions []int) int {
max := 0
for _, v := range versions {
if v > max {
max = v
}
}
return max
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -87,13 +149,25 @@ func Run(opt Options) error {
Service: "agent-system", Module: "app", Level: logsys.LevelInfo,
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
})
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir, "tier", tierName(opt))
resolved := config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
Tier: opt.Tier, PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
}}
if msg := resolved.ListenRefusal(); msg != "" {
return fmt.Errorf("listen guard: %s", msg)
}
// 部署档门禁(KTD1 fail-closed):team 档缺 DSN/未开认证直接拒绝启动;
// local 档带 DSN 是配置错误。此处与 config.TierProblems 同规则(纵深防御,
// 服务入口不依赖 CLI 是否校验)。
if msgs := TierRefusal(opt); len(msgs) > 0 {
return fmt.Errorf("tier guard: %s", strings.Join(msgs, ";"))
}
if opt.Tier == "team" && !opt.DisableExecTools && opt.ExecBackend == "" {
log.Printf("warning: team 档未配置执行隔离(disableExecTools 或 execBackend);向真实多用户开放前必须补齐(方案 KTD1)")
}
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
@@ -104,7 +178,20 @@ func Run(opt Options) error {
if artifacts == "" {
artifacts = filepath.Join(opt.DataDir, "artifacts")
}
st, err = store.OpenPostgres(opt.StorageDSN, artifacts)
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
blobs, berr := blobstore.NewS3FromEnv()
if berr != nil {
return fmt.Errorf("blobstore s3: %w", berr)
}
if blobs != nil {
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
}
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
if err != nil && opt.Tier == "team" {
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
}
} else {
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
}
@@ -112,7 +199,30 @@ func Run(opt Options) error {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
log.Printf("storage tier: %s", st.Dialect())
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if opt.StorageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
}
pool := st.Pool()
if versions, verr := st.MigrationStatus(); verr == nil {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, pool.ConnMaxIdleTimeSec,
pool.ConnMaxLifetimeSec, latestVersion(versions))
} else {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d schema=? migration status unavailable: %v)",
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, verr)
}
b := bus.New()
reg := tools.NewRegistry()
@@ -138,6 +248,20 @@ func Run(opt Options) error {
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
}
if opt.ExecDepsDir != "" {
tools.SetExecDepsDir(opt.ExecDepsDir)
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
}
if opt.ExecWorkspace == "overlay" {
tools.SetWorkspaceOverlay(true)
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
} else {
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
}
} else if opt.ExecWorkspace != "" {
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
}
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
@@ -392,15 +516,70 @@ func Run(opt Options) error {
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
log.Printf("surface: recovered %d pending events", n)
}
}()
mgr.SetCards(cardSvc)
// 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询;
// SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。
mgr.SetInstanceTag(instanceTag(opt), opt.Addr)
stopCommands := mgr.StartCommandConsumer(context.Background())
defer stopCommands()
// 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser
// 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效,
// 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。
mgr.SetExecutionBudgetFn(func() store.ExecutionBudget {
eff := setSvc.Effective(store.OwnerDefault, "")
return store.ExecutionBudget{
Global: int(eff.GetNumber("runtime.execBudgetGlobal", 0)),
PerUser: int(eff.GetNumber("runtime.execBudgetPerUser", 0)),
}
}, int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.execQueueMax", 200)))
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
state := "committed"
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
bus.SetToolGate(func(owner, commandID string) bool {
if !strings.HasPrefix(commandID, genui.PluginID+".") {
return true
}
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
})
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
mgr.SetGenUIAuto(genui.NewAuto(
func(owner string) bool {
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
},
func(owner string) bool {
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
},
func(owner, kind, detail string) {
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
},
))
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
@@ -412,7 +591,7 @@ func Run(opt Options) error {
mgr.SetCommandSource(busAdapter{bus: bus})
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
applyCompiled(skillsRoot, bus, caps)
compiledSummary := applyCompiled(skillsRoot, bus, caps)
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
status, aerr := st.AccountTokens(owner, tokens, budget)
@@ -465,6 +644,9 @@ func Run(opt Options) error {
srv.Workflows = wfReg
mgr.SetWorkflows(wfReg)
srv.Cards = cardSvc
srv.Surfaces = surfaceSvc
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
srv.CompiledPlugins = compiledSummary
srv.Bus = bus
srv.Gate = gate
srv.Prompts = promptSvc
@@ -648,6 +830,20 @@ func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
return st.RevokeAPIKeyByName(owner, name)
}
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.EnsureSystemIdentity(instanceID, dbref)
})
}
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.VerifySystemIdentity(instanceID, dbref)
})
}
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
func cachedInt(ttl time.Duration, fn func() int) func() int {
var mu sync.Mutex
@@ -726,7 +922,11 @@ func splitKeyPool(s string) []string {
type busAdapter struct{ bus *cmdbus.Bus }
func (a busAdapter) CommandTools() []runmgr.CommandTool {
src := a.bus.CommandTools()
return a.CommandToolsFor("")
}
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
src := a.bus.CommandToolsFor(owner)
out := make([]runmgr.CommandTool, len(src))
for i, ct := range src {
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
+92 -4
View File
@@ -11,6 +11,7 @@ import (
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/server"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
@@ -26,6 +27,29 @@ type pluginRegistry struct {
compiledRegistry
pluginID string
caps hostcaps
// summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此,
// server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。
summary *server.CompiledPlugin
}
// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录
// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝,
// 使 register() 整体中止(与命令注册同一失败语义)。
func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error {
if r.summary == nil {
return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled")
}
var s extension.AdminZoneSpec
if err := json.Unmarshal(spec, &s); err != nil {
return fmt.Errorf("extension: admin zone spec: %w", err)
}
if s.ID == "" || s.Title == "" {
return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID)
}
r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{
ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order,
})
return nil
}
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
@@ -40,10 +64,11 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
return fmt.Errorf("extension: command spec: %w", err)
}
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
HostProjectID: req.HostProjectID,
})
})
}
@@ -104,17 +129,80 @@ func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error
return nil
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要
// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。
type recordRegistry struct {
extension.Registry
sum *server.CompiledPlugin
}
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
if err := r.Registry.AddCommand(spec); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Commands = append(r.sum.Commands, id)
}
return nil
}
func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
if err := r.Registry.AddCommandHandler(spec, h); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Commands = append(r.sum.Commands, id)
}
return nil
}
func (r recordRegistry) AddIntent(spec json.RawMessage) error {
if err := r.Registry.AddIntent(spec); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Intents = append(r.sum.Intents, id)
}
return nil
}
func (r recordRegistry) AddSkill(name, markdown string) error {
if err := r.Registry.AddSkill(name, markdown); err != nil {
return err
}
r.sum.Skills = append(r.sum.Skills, name)
return nil
}
// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串
// 不记录,不影响注册结果本身。
func specID(spec json.RawMessage) string {
var s struct {
ID string `json:"id"`
}
if err := json.Unmarshal(spec, &s); err != nil {
return ""
}
return s.ID
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin {
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
var out []server.CompiledPlugin
for _, e := range extension.Compiled() {
if e.Register == nil {
continue
}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
if err := e.Register(reg); err != nil {
sum := server.CompiledPlugin{ID: e.ID}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum}
// Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册
// 生效),摘要如实反映实际注册面。
if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil {
log.Printf("extension: %s: %v", e.ID, err)
}
out = append(out, sum)
}
return out
}
func mergeMCPServers(raw string, extra []core.PluginMCPServer) string {
+92
View File
@@ -0,0 +1,92 @@
package app
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入
// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。
func TestApplyCompiledRecordsSummary(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error {
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
return "ok", nil
}); err != nil {
return err
}
// 重复 id 注册失败:总线拒绝,摘要不记录。
if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil {
t.Fatal("duplicate command should fail")
}
if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil {
return err
}
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
// WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。
if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil {
return err
}
if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil {
t.Fatal("admin zone without id should fail")
}
return nil
}})
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil {
return err
}
return reg.AddCommandHandler(json.RawMessage(`not-json`), nil)
}})
// 无 Register 的登记不产生条目。
extension.RegisterCompiled(extension.InProc{ID: "empty"})
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)})
if len(sums) != 2 {
t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums)
}
if sums[0].ID != "demo" {
t.Fatalf("sums[0] = %+v", sums[0])
}
if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" {
t.Fatalf("demo commands = %v", sums[0].Commands)
}
if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" {
t.Fatalf("demo intents = %v", sums[0].Intents)
}
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
t.Fatalf("demo skills = %v", sums[0].Skills)
}
// WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。
if len(sums[0].AdminZones) != 1 {
t.Fatalf("demo admin zones = %+v", sums[0].AdminZones)
}
if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 {
t.Fatalf("demo admin zone = %+v", z)
}
// broken:Register 报错但先成功的命令仍在总线,摘要如实保留。
if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" {
t.Fatalf("broken summary = %+v", sums[1])
}
// 摘要与总线一致:记录的命令真实可分发(不是空头衔)。
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason)
}
}
+62
View File
@@ -0,0 +1,62 @@
package app
// EXT-18 AdminProvider 正负例:admin 主体 → true;非 admin / 空 actor(scoped
// host)→ false;store 查询失败 → false(fail-closed)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestExt18AdminProviderPositive(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: "boss"}
if !h.IsAdmin() {
t.Fatal("admin 主体应返回 true")
}
}
func TestExt18AdminProviderNegative(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("user", "User", "pw", false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "user", actor: "user"}
if h.IsAdmin() {
t.Fatal("非 admin 主体应返回 false")
}
}
func TestExt18AdminProviderEmptyActor(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: ""} // scoped host(无 dispatch)
if h.IsAdmin() {
t.Fatal("空 actor(非 dispatch 上下文)恒 false")
}
}
func TestExt18RequestHostProjectIDField(t *testing.T) {
// 编译期断言:Request 有 HostProjectID 字段。
_ = ""
var r struct{ HostProjectID string }
r.HostProjectID = "proj-1"
if r.HostProjectID != "proj-1" {
t.Fatal("字段赋值失败")
}
}
+69 -4
View File
@@ -11,6 +11,7 @@ import (
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
@@ -18,10 +19,11 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
@@ -113,15 +115,32 @@ func (c hostcaps) parse(ctx context.Context, filename, mime string, content []by
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
}
func (h inprocHost) PluginID() string { return h.pluginID }
func (h inprocHost) Owner() string { return h.owner }
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
// (fail-closed——不因存储故障放行超管面)。
func (h inprocHost) IsAdmin() bool {
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil {
return false
}
return state.IsAdmin
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
@@ -230,3 +249,49 @@ func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
return h.caps.parse(ctx, filename, mime, content)
}
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
func (h inprocHost) surface() (*surfacesvc.Service, error) {
if h.caps.surfaces == nil {
return nil, surfacesvc.ErrUnavailable
}
return h.caps.surfaces, nil
}
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
})
}
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Get(ctx, h.owner, surfaceID)
}
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
svc, err := h.surface()
if err != nil {
return nil, err
}
return svc.List(ctx, h.owner, conversationID)
}
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
svc, err := h.surface()
if err != nil {
return false, 0, err
}
row, err := svc.Activation(ctx, h.owner, h.pluginID)
return row.Enabled, row.Epoch, err
}
+31
View File
@@ -0,0 +1,31 @@
package app
import (
"strings"
"testing"
)
// 协作存储方案 U2(KTD1/R2):服务入口的部署档门禁。
// team 档缺 DSN / 未开认证必须拒绝启动——不创建 SQLite、不进入装配。
func TestTierRefusalTeam(t *testing.T) {
msgs := TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788"})
if len(msgs) != 2 {
t.Fatalf("team without dsn/auth: %v", msgs)
}
joined := strings.Join(msgs, ";")
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
t.Fatalf("refusal wording: %v", msgs)
}
msgs = TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db", AuthMode: "on"})
if len(msgs) != 0 {
t.Fatalf("team with dsn+auth should pass: %v", msgs)
}
msgs = TierRefusal(Options{Tier: "local", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db"})
if len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
t.Fatalf("local with dsn: %v", msgs)
}
msgs = TierRefusal(Options{Addr: "127.0.0.1:8788"})
if len(msgs) != 0 {
t.Fatalf("auto default: %v", msgs)
}
}
+121
View File
@@ -0,0 +1,121 @@
package blobstore
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// U5/KTD9:S3 兼容对象存储实现——针对嵌入式测试服务器验证
// 签名请求、Put/Get/Delete 语义与预签名 URL 生成。
// (真实 S3 兼容服务的端到端验收随 U7 压测环境执行;此处锁定协议正确性。)
func newTestS3(t *testing.T, base string) *S3 {
t.Helper()
s, err := NewS3(base, "us-east-1", "AKIDEXAMPLE", "secret", "art/")
if err != nil {
t.Fatal(err)
}
return s
}
func TestS3PutGetDeleteRoundTrip(t *testing.T) {
var gotPath, gotMethod, gotAuth string
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = buf[:n]
switch r.Method {
case http.MethodPut:
w.WriteHeader(200)
case http.MethodGet:
if strings.Contains(gotPath, "obj404") {
w.WriteHeader(404)
return
}
w.Write([]byte("payload"))
case http.MethodDelete:
w.WriteHeader(204)
}
}))
defer srv.Close()
s := newTestS3(t, srv.URL+"/bucket")
ref, err := s.Put("doc1", []byte("payload"))
if err != nil {
t.Fatal(err)
}
if ref != "art/doc1" {
t.Fatalf("ref=%s(对象键应带前缀)", ref)
}
if gotPath != "/bucket/art/doc1" || gotMethod != http.MethodPut || string(gotBody) != "payload" {
t.Fatalf("put 请求:%s %s body=%q", gotMethod, gotPath, gotBody)
}
if len(gotAuth) == 0 || gotAuth[:17] != "AWS4-HMAC-SHA256 " {
t.Fatalf("Authorization 头:%q", gotAuth)
}
got, err := s.Get(ref)
if err != nil || string(got) != "payload" {
t.Fatalf("get: %q err=%v", got, err)
}
if err := s.Delete(ref); err != nil {
t.Fatalf("delete: %v", err)
}
// 404 → 哨兵错误(服务端对 art/obj404 的 GET 返回 404)。
if _, err := s.Get("art/obj404"); err == nil || err.Error() != ErrObjectNotFound.Error() {
t.Fatalf("404 应 ErrObjectNotFound:%v", err)
}
// Delete 幂等(404 视为成功)。
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv2.Close()
s2 := newTestS3(t, srv2.URL+"/b")
if err := s2.Delete("art/none"); err != nil {
t.Fatalf("delete 404 应幂等:%v", err)
}
}
func TestS3PresignGet(t *testing.T) {
s := newTestS3(t, "https://s3.example.com/bucket")
u, err := s.PresignGet("art/doc1", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"X-Amz-Algorithm=AWS4-HMAC-SHA256", "X-Amz-Signature=", "X-Amz-Expires=900", "/bucket/art/doc1"} {
if !contains(u, want) {
t.Fatalf("预签名 URL 缺 %s:%s", want, u)
}
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
func TestNewS3FromEnvFallback(t *testing.T) {
os.Unsetenv("BLOBSTORE_S3_ENDPOINT")
s, err := NewS3FromEnv()
if s != nil || err != nil {
t.Fatalf("未配置端点应回落 (nil,nil):s=%v err=%v", s, err)
}
t.Setenv("BLOBSTORE_S3_ENDPOINT", "http://x/b")
t.Setenv("BLOBSTORE_S3_ACCESS_KEY", "a")
t.Setenv("BLOBSTORE_S3_SECRET_KEY", "b")
s, err = NewS3FromEnv()
if err != nil || s == nil {
t.Fatalf("已配置应构造:s=%v err=%v", s, err)
}
}
+242
View File
@@ -0,0 +1,242 @@
package blobstore
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// S3 是 S3 兼容对象存储实现(协作存储 U5/KTD9:多实例共享工件)。
// 认证:AWS Signature V4(静态 AccessKey/SecretKey;凭据只经环境或受保护
// 配置注入,引用形态为对象键,不含凭据)。
//
// 语义(KTD9):
// - Put:先完整写对象再由上层提交引用(失败孤儿可清理);
// - Get:按对象键读取;
// - Delete:立即删除(标记/延迟 GC 由桶生命周期策略承担)。
type S3 struct {
Base string // 端点(含 bucket 路径,如 https://s3.example.com/bucket)
Region string
Access string
Secret string
Prefix string // 对象键前缀(租户/应用隔离)
client *http.Client
nowFunc func() time.Time
}
// NewS3 从显式参数构造;nil client 用默认。
func NewS3(base, region, access, secret, prefix string) (*S3, error) {
if base == "" || access == "" || secret == "" {
return nil, errors.New("blobstore: s3 requires base/access/secret")
}
if !strings.HasSuffix(base, "/") {
base += "/"
}
return &S3{Base: base, Region: region, Access: access, Secret: secret, Prefix: prefix,
client: &http.Client{Timeout: 30 * time.Second}, nowFunc: time.Now}, nil
}
// NewS3FromEnv 按环境构造:BLOBSTORE_S3_ENDPOINT/REGION/ACCESS_KEY/SECRET_KEY/PREFIX。
// 未配置端点返回 (nil, nil)(调用方回落本地档)。
func NewS3FromEnv() (*S3, error) {
if os.Getenv("BLOBSTORE_S3_ENDPOINT") == "" {
return nil, nil
}
return NewS3(os.Getenv("BLOBSTORE_S3_ENDPOINT"),
envOr("BLOBSTORE_S3_REGION", "us-east-1"),
os.Getenv("BLOBSTORE_S3_ACCESS_KEY"),
os.Getenv("BLOBSTORE_S3_SECRET_KEY"),
os.Getenv("BLOBSTORE_S3_PREFIX"))
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func (s *S3) key(id string) (string, error) {
if !validID(id) {
return "", fmt.Errorf("blobstore: invalid object id %q", id)
}
return s.Prefix + id, nil
}
// Put 上传对象;ref=对象键。
func (s *S3) Put(id string, content []byte) (string, error) {
key, err := s.key(id)
if err != nil {
return "", err
}
resp, err := s.do(http.MethodPut, key, content, "")
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return "", fmt.Errorf("blobstore: s3 put %s: %s %s", key, resp.Status, body)
}
return key, nil
}
// Get 下载对象。
func (s *S3) Get(ref string) ([]byte, error) {
resp, err := s.do(http.MethodGet, ref, nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrObjectNotFound
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("blobstore: s3 get %s: %s", ref, resp.Status)
}
return io.ReadAll(resp.Body)
}
// ErrObjectNotFound 对象不存在(与本地档 ErrNotExist 对齐的哨兵)。
var ErrObjectNotFound = errors.New("blobstore: object not found")
// Delete 删除对象(幂等:404 视为成功)。
func (s *S3) Delete(ref string) error {
resp, err := s.do(http.MethodDelete, ref, nil, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("blobstore: s3 delete %s: %s", ref, resp.Status)
}
// do 签名并执行请求(AWS SigV4,service=s3)。
func (s *S3) do(method, key string, body []byte, contentType string) (*http.Response, error) {
u, err := url.Parse(s.Base + key)
if err != nil {
return nil, err
}
now := s.nowFunc().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256.Sum256(body)
payloadHex := hex.EncodeToString(payloadHash[:])
req, err := http.NewRequest(method, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHex)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// 规范请求与签名(SigV4 单 chunk)。
canonicalURI := "/" + strings.TrimPrefix(u.Path, "/")
canonicalQuery := u.RawQuery
var ch bytes.Buffer
ch.WriteString(method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n")
ch.WriteString("host:" + u.Host + "\n")
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
ch.WriteString("x-amz-date:" + amzDate + "\n")
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
cr := ch.Bytes()
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(cr))
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
kRegion := hmacSHA256(kDate, s.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+s.Access+"/"+scope+
", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="+sig)
return s.client.Do(req)
}
func sha256sum(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}
// PresignGet 生成限时 GET 预签名 URL(下载入口;有效期由调用方指定)。
// 用于把大对象下载直接指向对象存储,应用实例不做数据中继。
func (s *S3) PresignGet(ref string, ttl time.Duration) (string, error) {
u, err := url.Parse(s.Base + ref)
if err != nil {
return "", err
}
now := s.nowFunc().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
expiry := int64(ttl / time.Second)
payloadHex := hex.EncodeToString(sha256sum(nil))
q := u.Query()
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
q.Set("X-Amz-Credential", s.Access+"/"+dateStamp+"/"+s.Region+"/s3/aws4_request")
q.Set("X-Amz-Date", amzDate)
q.Set("X-Amz-Expires", fmt.Sprint(expiry))
q.Set("X-Amz-SignedHeaders", "host")
u.RawQuery = q.Encode()
canonicalQuery := canonicalQueryEncode(u)
var ch bytes.Buffer
ch.WriteString("GET\n/" + strings.TrimPrefix(u.Path, "/") + "\n" + canonicalQuery + "\n")
ch.WriteString("host:" + u.Host + "\n")
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
ch.WriteString("x-amz-date:" + amzDate + "\n")
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(ch.Bytes()))
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
kRegion := hmacSHA256(kDate, s.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
return u.String() + "&X-Amz-Signature=" + sig, nil
}
// canonicalQueryEncode 按 AWS 规则重排查询串(键排序 + URL 编码)。
func canonicalQueryEncode(u *url.URL) string {
q := u.Query()
var keys []string
for k := range q {
keys = append(keys, k)
}
// 排序(简单实现足够:键集固定且互不冲突)
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
var sb strings.Builder
for i, k := range keys {
if i > 0 {
sb.WriteString("&")
}
sb.WriteString(url.QueryEscape(k) + "=" + url.QueryEscape(q[k][0]))
}
return sb.String()
}
var _ Store = (*S3)(nil)
+22 -8
View File
@@ -95,6 +95,7 @@ func (s *Service) List(owner, conversationID string) ([]core.ActivityCard, error
}
// Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。
// 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。
func (s *Service) Update(owner string, card core.ActivityCard) error {
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCard(owner, &card, ""); err != nil {
@@ -103,6 +104,16 @@ func (s *Service) Update(owner string, card core.ActivityCard) error {
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
}
// UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict,
// HTTP 层映射 409;客户端重读卡后重试。
func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error {
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil {
return err
}
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
}
// Append 流式追加正文(card_append 工具;D45):文本并入 body 尾部 markdown
// 节点(尾节点非 markdown 则新增),逐次持久化;card.updated 按 cardAppendWindow
// 节流——窗口内的追加只落库不发布(投影语义:事件携带整卡,后续事件自然覆盖
@@ -141,7 +152,7 @@ func (s *Service) Append(owner, id, text string) error {
// SetStatus 状态迁移:更新并发布 card.status。
func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, err
}
@@ -149,7 +160,7 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
if tone != "" {
c.Tone = tone
}
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, err
}
return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note})
@@ -157,12 +168,12 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
// SetPinned 置顶/取消(收件箱"不沉没"语义)。
func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, err
}
c.Pinned = pinned
return c, s.Update(owner, c)
return c, s.UpdateVersioned(owner, c, ver)
}
// Dismiss 关闭卡(非确认语义,留痕)。
@@ -173,7 +184,7 @@ func (s *Service) Dismiss(owner, id string) (core.ActivityCard, error) {
// Action 处理卡上按钮点击:仅 action_required 卡可裁决;结果 resolved 并发布
// card.resolved(ActionID 即按钮语义)。
func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCard, string, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, "", err
}
@@ -197,7 +208,7 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar
return c, "", ErrNoAction
}
c.Status = core.CardResolved
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, "", err
}
return c, label, s.emitCard(c, core.EvCardResolved, core.CardResolvedPayload{CardID: c.ID, ActionID: actionID, Answer: freeText})
@@ -250,7 +261,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
}
return err
}
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return err
}
@@ -258,7 +269,10 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
return nil // 已决(双现任一侧先到)
}
c.Status = core.CardResolved
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
if errors.Is(err, store.ErrConflict) {
return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准
}
return err
}
if c.ConversationID != "" && s.emit != nil {
+18
View File
@@ -45,12 +45,28 @@ type CommandTool struct {
}
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
type ToolGate func(owner, commandID string) bool
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
func (b *Bus) CommandTools() []CommandTool {
return b.CommandToolsFor("")
}
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
// gated 命令一律不投影——fail-closed)。
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
out := []CommandTool{}
for _, r := range b.reg.List() {
if !r.Enabled || r.Spec.ExposeAsTool == nil {
continue
}
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
continue
}
risk := r.Spec.ExposeAsTool.Risk
if risk == "" {
risk = "low"
@@ -75,6 +91,7 @@ type Request struct {
ConversationID string
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
Confirmed bool // 危险命令经确认卡放行后的执行
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
}
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
@@ -101,6 +118,7 @@ type Bus struct {
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
+28
View File
@@ -6,6 +6,7 @@ package cmdbus
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -72,3 +73,30 @@ func TestCommandToolDispatchEnvelope(t *testing.T) {
type nopAudit struct{}
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
// U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed
// (owner 为空且 gate 判否时不投影)。
func TestCommandToolsForGate(t *testing.T) {
bus := New(nil, nil, func() time.Time { return time.Now().UTC() })
if err := bus.RegisterPluginCommands([]json.RawMessage{
[]byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
[]byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
}); err != nil {
t.Fatal(err)
}
bus.SetToolGate(func(owner, id string) bool {
return !strings.HasPrefix(id, "generative-ui.")
})
on := bus.CommandToolsFor("alice")
for _, ct := range on {
if strings.HasPrefix(ct.ID, "generative-ui.") {
t.Fatalf("gated command leaked for enabled owner: %+v", ct)
}
}
if len(on) != 1 {
t.Fatalf("expected only other.echo, got %+v", on)
}
if n := len(bus.CommandToolsFor("")); n != 1 {
t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n)
}
}
+232 -24
View File
@@ -11,6 +11,7 @@ import (
"io"
"net"
"os"
"strconv"
"strings"
)
@@ -31,6 +32,21 @@ type Config struct {
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
// 隔离档)。DisableExecTools 优先级高于后端选择。
ExecBackend string `json:"execBackend"`
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
ExecDepsDir string `json:"execDepsDir"`
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
ExecWorkspace string `json:"execWorkspace"`
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
// PG 故障拒绝就绪,绝不回落 SQLite)。
Tier string `json:"tier"`
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
PGMaxOpen int `json:"pgMaxOpen"`
PGMaxIdle int `json:"pgMaxIdle"`
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
}
var defaults = Config{
@@ -42,18 +58,26 @@ var defaults = Config{
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
var envKey = map[string]string{
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
"tier": "AGENT_SYSTEM_TIER",
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
}
// Source 配置来源层级(数值越大优先级越高)。
@@ -103,11 +127,7 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
}
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
for f := range present {
if isBoolField(f) {
setBool(&cfg, f, boolField(fc, f))
} else {
setString(&cfg, f, stringField(fc, f))
}
setField(&cfg, f, fc)
src[f] = SrcFile
}
}
@@ -120,6 +140,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
return Resolved{}, err
}
setBool(&cfg, f, b)
} else if isIntField(f) {
n, err := parseInt(key, v)
if err != nil {
return Resolved{}, err
}
setInt(&cfg, f, n)
} else {
setString(&cfg, f, v)
}
@@ -129,15 +155,22 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
// CLI 层:仅显式传入。
for f := range cliSet {
src[f] = SrcCLI
if isBoolField(f) {
setBool(&cfg, f, boolField(cli, f))
} else {
setString(&cfg, f, stringField(cli, f))
}
setField(&cfg, f, cli)
}
return Resolved{Config: cfg, sources: src}, nil
}
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
func setField(dst *Config, f string, src Config) {
if isBoolField(f) {
setBool(dst, f, boolField(src, f))
} else if isIntField(f) {
setInt(dst, f, intField(src, f))
} else {
setString(dst, f, stringField(src, f))
}
}
func loadFile(path string) (Config, map[string]bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
@@ -176,6 +209,22 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.DisableExecTools
case "execBackend":
dst = &c.ExecBackend
case "execDepsDir":
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
case "tier":
dst = &c.Tier
case "pgMaxOpen":
dst = &c.PGMaxOpen
case "pgMaxIdle":
dst = &c.PGMaxIdle
case "pgConnMaxIdleSec":
dst = &c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
dst = &c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
dst = &c.PGQueryTimeoutSec
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -207,6 +256,12 @@ func stringField(c Config, f string) string {
return c.AllowedHostsRaw
case "execBackend":
return c.ExecBackend
case "execDepsDir":
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
case "tier":
return c.Tier
}
return ""
}
@@ -215,6 +270,45 @@ func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
}
func isIntField(f string) bool {
switch f {
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
return true
}
return false
}
func intField(c Config, f string) int {
switch f {
case "pgMaxOpen":
return c.PGMaxOpen
case "pgMaxIdle":
return c.PGMaxIdle
case "pgConnMaxIdleSec":
return c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
return c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
return c.PGQueryTimeoutSec
}
return 0
}
func setInt(c *Config, f string, v int) {
switch f {
case "pgMaxOpen":
c.PGMaxOpen = v
case "pgMaxIdle":
c.PGMaxIdle = v
case "pgConnMaxIdleSec":
c.PGConnMaxIdleSec = v
case "pgConnMaxLifetimeSec":
c.PGConnMaxLifetimeSec = v
case "pgQueryTimeoutSec":
c.PGQueryTimeoutSec = v
}
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
@@ -257,6 +351,12 @@ func setString(c *Config, f, v string) {
c.AllowedHostsRaw = v
case "execBackend":
c.ExecBackend = v
case "execDepsDir":
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
case "tier":
c.Tier = v
}
}
@@ -270,6 +370,14 @@ func parseBool(name, v string) (bool, error) {
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
}
func parseInt(name, v string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
}
return n, nil
}
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
func (r Resolved) Explain(w io.Writer, file string) {
if file != "" {
@@ -278,16 +386,32 @@ func (r Resolved) Explain(w io.Writer, file string) {
fmt.Fprintln(w, "config file: (none)")
}
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend"} {
v := stringField(r.Config, f)
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
var v string
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
} else if isIntField(f) {
n := intField(r.Config, f)
if d := intDefault(f); n == 0 && d != 0 {
v = fmt.Sprintf("%d (默认)", d)
} else {
v = fmt.Sprintf("%d", n)
}
} else {
v = stringField(r.Config, f)
}
if f == "dsn" {
v = redact(v)
}
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
}
if msgs := r.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
}
} else {
fmt.Fprintln(w, "tier guard: 放行")
}
if msg := r.ListenRefusal(); msg != "" {
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
@@ -302,6 +426,90 @@ func (r Resolved) AuthEnabled() bool {
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
}
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
func intDefault(f string) int {
switch f {
case "pgMaxOpen":
return DefaultPGMaxOpen
case "pgMaxIdle":
return DefaultPGMaxIdle
case "pgConnMaxIdleSec":
return DefaultPGConnMaxIdleSec
}
return 0
}
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
const (
DefaultPGMaxOpen = 16
DefaultPGMaxIdle = 8
DefaultPGConnMaxIdleSec = 300
)
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
func (r Resolved) TierProblems() []string {
var msgs []string
switch r.Tier {
case "", "local", "team":
default:
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
}
if r.Tier == "team" {
if r.StorageDSN == "" {
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
}
if !r.AuthEnabled() {
msgs = append(msgs, "team 档必须开启认证(auth=on)")
}
}
if r.Tier == "local" && r.StorageDSN != "" {
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
}
if v := r.PGMaxOpen; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
}
if v := r.PGMaxIdle; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxIdleSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxLifetimeSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
}
if v := r.PGQueryTimeoutSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
}
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
}
return msgs
}
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
func (r Resolved) EffectivePGMaxOpen() int {
if r.PGMaxOpen > 0 {
return r.PGMaxOpen
}
return DefaultPGMaxOpen
}
func (r Resolved) EffectivePGMaxIdle() int {
if r.PGMaxIdle > 0 {
return r.PGMaxIdle
}
return DefaultPGMaxIdle
}
func (r Resolved) EffectivePGConnMaxIdleSec() int {
if r.PGConnMaxIdleSec > 0 {
return r.PGConnMaxIdleSec
}
return DefaultPGConnMaxIdleSec
}
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
func (r Resolved) AllowedHosts() []string {
var out []string
+133
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -220,3 +221,135 @@ func TestListenGuard(t *testing.T) {
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
}
}
// ---- 协作存储方案 U2:部署档(KTD1)与连接预算(KTD12)----
// team 档 fail-closed:缺 DSN、未开认证都拒绝;DSN+auth=on 放行。
func TestTierTeamFailClosed(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
msgs := r.TierProblems()
if len(msgs) != 2 {
t.Fatalf("team without dsn/auth should refuse with 2 problems, got %v", msgs)
}
joined := msgs[0] + msgs[1]
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
t.Fatalf("refusal messages wrong: %v", msgs)
}
// 补上 DSN(auto 认证随 DSN 开启)→ 放行。
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
if err != nil {
t.Fatal(err)
}
if msgs = r.TierProblems(); len(msgs) != 0 {
t.Fatalf("team with dsn should pass, got %v", msgs)
}
// 显式关认证仍然拒绝。
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db", AuthMode: "off"}, map[string]bool{"tier": true, "dsn": true, "auth": true})
if err != nil {
t.Fatal(err)
}
if msgs = r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "认证") {
t.Fatalf("team with auth=off should refuse, got %v", msgs)
}
}
// local 档不允许 DSN;未知档名拒绝。
func TestTierLocalAndUnknown(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "local", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
t.Fatalf("local with dsn should refuse, got %v", msgs)
}
r, err = Load("", noEnv, Config{Tier: "cluster"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "未知部署档") {
t.Fatalf("unknown tier should refuse, got %v", msgs)
}
// auto(空)档维持现状语义:无 DSN 不拒绝。
r, err = Load("", noEnv, Config{}, nil)
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 0 {
t.Fatalf("auto default should pass, got %v", msgs)
}
}
// 连接预算四源解析与环境变量整数校验;MaxIdle>MaxOpen 拒绝。
func TestPGPoolConfigLayers(t *testing.T) {
env := func(k string) (string, bool) {
switch k {
case "AGENT_SYSTEM_PG_MAX_OPEN":
return "7", true
case "AGENT_SYSTEM_PG_MAX_IDLE":
return "9", true
}
return "", false
}
f := fileWith(t, `{"pgMaxOpen":5}`)
r, err := Load(f, env, Config{PGMaxOpen: 3, PGMaxIdle: 2}, map[string]bool{"pgMaxIdle": true})
if err != nil {
t.Fatal(err)
}
// 文件 5 被 env 7 覆盖(CLI 值 3 未显式传入不参与);pgMaxIdle CLI 显式 2 胜。
if r.PGMaxOpen != 7 || r.SourceOf("pgMaxOpen") != SrcEnv {
t.Fatalf("pgMaxOpen = %d (%v), want env 7", r.PGMaxOpen, r.SourceOf("pgMaxOpen"))
}
if r.PGMaxIdle != 2 || r.SourceOf("pgMaxIdle") != SrcCLI {
t.Fatalf("pgMaxIdle = %d (%v), want CLI 2", r.PGMaxIdle, r.SourceOf("pgMaxIdle"))
}
if msgs := r.TierProblems(); len(msgs) != 0 {
t.Fatalf("2/2 pool valid, got %v", msgs)
}
// 有效值:零值落默认。
r, err = Load("", noEnv, Config{}, nil)
if err != nil {
t.Fatal(err)
}
if r.EffectivePGMaxOpen() != DefaultPGMaxOpen || r.EffectivePGMaxIdle() != DefaultPGMaxIdle || r.EffectivePGConnMaxIdleSec() != DefaultPGConnMaxIdleSec {
t.Fatalf("effective defaults wrong: %+v", r.Config)
}
// idle > open 拒绝。
r, err = Load("", noEnv, Config{PGMaxOpen: 2, PGMaxIdle: 3}, map[string]bool{"pgMaxOpen": true, "pgMaxIdle": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "pgMaxIdle(3) 大于 pgMaxOpen(2)") {
t.Fatalf("idle>open should refuse, got %v", msgs)
}
// 非法整数环境变量报错定位到变量。
bad := func(k string) (string, bool) {
if k == "AGENT_SYSTEM_PG_MAX_OPEN" {
return "many", true
}
return "", false
}
if _, err := Load("", bad, Config{}, nil); err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_PG_MAX_OPEN") {
t.Fatalf("invalid int env should fail with var name, got %v", err)
}
}
// explain 输出包含部署档与预算行,且 tier guard 结论可见(脱敏诊断)。
func TestExplainTierAndPool(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
var b bytes.Buffer
r.Explain(&b, "")
out := b.String()
for _, want := range []string{"tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec", "tier guard: 拒绝启动"} {
if !strings.Contains(out, want) {
t.Fatalf("explain missing %q:\n%s", want, out)
}
}
}
+53
View File
@@ -41,6 +41,9 @@ type AssembleOptions struct {
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
ThinkReplay bool
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
Surfaces []core.SurfaceSnapshot
}
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
@@ -58,6 +61,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
rep.Items = append(rep.Items, "system(画像自定义段)")
}
if len(opts.Surfaces) > 0 {
var b strings.Builder
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
for _, snap := range opts.Surfaces {
b.WriteString(projectSurface(snap))
b.WriteString("\n")
}
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
}
maxHist := prof.Context.MaxHistoryMessages
if maxHist <= 0 {
@@ -499,3 +513,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
return out
}
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
func projectSurface(snap core.SurfaceSnapshot) string {
var b strings.Builder
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
if snap.SemanticText != "" {
fmt.Fprintf(&b, ":%s", snap.SemanticText)
}
b.WriteString("\n 结构:")
var walk func(n core.UINode)
first := true
walk = func(n core.UINode) {
label := string(n.Type)
if n.Title != "" {
label += "(\"" + n.Title + "\")"
}
if n.ID != "" {
label += "#" + n.ID
}
if len(n.Source) > 0 {
label += "[" + strings.Join(n.Source, ",") + "]"
}
if first {
b.WriteString(label)
first = false
} else {
b.WriteString(" > " + label)
}
for _, c := range n.Children {
walk(c)
}
}
for _, n := range snap.Spec.Nodes {
walk(n)
}
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
return b.String()
}
+52
View File
@@ -0,0 +1,52 @@
package ctxasm
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func surfaceSnap() core.SurfaceSnapshot {
return core.SurfaceSnapshot{
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
Revision: 2,
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
}},
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
}},
SemanticText: "三工具对比页",
}
}
func TestSurfaceProjectionInContext(t *testing.T) {
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
joined := make([]string, len(msgs))
for i, m := range msgs {
joined[i] = m.Content
}
all := strings.Join(joined, "\n---\n")
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
if !strings.Contains(all, need) {
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
}
}
found := false
for _, it := range rep.Items {
if strings.HasPrefix(it, "surface(") {
found = true
}
}
if !found {
t.Fatalf("report should count surface injection: %v", rep.Items)
}
_ = agentllm.RoleSystem
}
+95
View File
@@ -0,0 +1,95 @@
package genui
import (
"sync"
"time"
)
// AutoBudgetPerHour 是每任务(conversation)滑动一小时内允许的自动建议次数。
const AutoBudgetPerHour = 3
// AutoCooldown 是同会话两次自动触发的最小间隔。
const AutoCooldown = 60 * time.Second
// SettingAutoPresent 是 owner 级 opt-in 设置键(默认 false;与 activation 分离)。
const SettingAutoPresent = "plugins.generative-ui.autoPresent"
// AutoReminder 是注入 system-reminder 的建议文案(只引导 present,禁止改任务/点动作)。
const AutoReminder = "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
// ActivationLookup 查询 owner 的 Surface activation(generative-ui)。
type ActivationLookup func(owner string) (enabled bool)
// SettingsLookup 查询 owner 的 autoPresent opt-in。
type SettingsLookup func(owner string) (enabled bool)
// Auditor 写审计事件(耗尽等);nil = 跳过。
type Auditor func(owner, kind, detail string)
// Auto 是自动触发闸门:opt-in ∧ activation ∧ 预算 ∧ 冷却。
// 触发源由 runmgr 显式调用;present/surface/action 路径不得调用。
type Auto struct {
activation ActivationLookup
settings SettingsLookup
audit Auditor
now func() time.Time
mu sync.Mutex
budget map[string][]time.Time // convID → 触发时刻
last map[string]time.Time // convID → 上次成功放行
}
// NewAuto 构造闸门(lookup 不得为 nil)。
func NewAuto(activation ActivationLookup, settings SettingsLookup, audit Auditor) *Auto {
return &Auto{
activation: activation,
settings: settings,
audit: audit,
now: func() time.Time { return time.Now().UTC() },
budget: map[string][]time.Time{},
last: map[string]time.Time{},
}
}
// Try 尝试放行一次自动建议。ok=false 时 skip 含原因(供测试);ok=true 时调用方应注入 AutoReminder。
func (a *Auto) Try(owner, convID, reason string) (ok bool, skip string) {
if a == nil || a.activation == nil || a.settings == nil {
return false, "unconfigured"
}
if owner == "" || convID == "" {
return false, "missing_ids"
}
if !a.settings(owner) {
return false, "opt_in_off"
}
if !a.activation(owner) {
return false, "activation_off"
}
now := a.now()
a.mu.Lock()
defer a.mu.Unlock()
if t, hit := a.last[convID]; hit && now.Sub(t) < AutoCooldown {
return false, "cooldown"
}
cutoff := now.Add(-time.Hour)
kept := a.budget[convID][:0]
for _, t := range a.budget[convID] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
a.budget[convID] = kept
if len(kept) >= AutoBudgetPerHour {
if a.audit != nil {
a.audit(owner, "genui.auto_present_exhausted", "conversation="+convID+" reason="+reason)
}
return false, "budget"
}
a.budget[convID] = append(kept, now)
a.last[convID] = now
return true, ""
}
+53
View File
@@ -0,0 +1,53 @@
package genui
import (
"testing"
"time"
)
func TestAutoDefaultOff(t *testing.T) {
a := NewAuto(func(string) bool { return true }, func(string) bool { return false }, nil)
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "opt_in_off" {
t.Fatalf("want opt_in_off, got ok=%v skip=%q", ok, skip)
}
}
func TestAutoRequiresActivation(t *testing.T) {
a := NewAuto(func(string) bool { return false }, func(string) bool { return true }, nil)
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "activation_off" {
t.Fatalf("want activation_off, got ok=%v skip=%q", ok, skip)
}
}
func TestAutoBudgetAndCooldown(t *testing.T) {
var exhausted int
now := time.Unix(1_700_000_000, 0).UTC()
a := NewAuto(func(string) bool { return true }, func(string) bool { return true },
func(owner, kind, detail string) {
if kind == "genui.auto_present_exhausted" {
exhausted++
}
})
a.now = func() time.Time { return now }
for i := 0; i < AutoBudgetPerHour; i++ {
ok, skip := a.Try("alice", "c1", "plan")
if !ok {
t.Fatalf("shot %d should pass, skip=%q", i, skip)
}
now = now.Add(AutoCooldown + time.Second)
}
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "budget" || exhausted != 1 {
t.Fatalf("want budget exhausted, ok=%v skip=%q exhausted=%d", ok, skip, exhausted)
}
// 冷却:同一时刻第二次拒绝
a2 := NewAuto(func(string) bool { return true }, func(string) bool { return true }, nil)
a2.now = func() time.Time { return now }
if ok, _ := a2.Try("alice", "c2", "art"); !ok {
t.Fatal("first should pass")
}
if ok, skip := a2.Try("alice", "c2", "art"); ok || skip != "cooldown" {
t.Fatalf("want cooldown, got ok=%v skip=%q", ok, skip)
}
}
+539
View File
@@ -0,0 +1,539 @@
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
package genui
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// PluginID 是插件身份(activation 台账与命令前缀共用)。
const PluginID = "generative-ui"
const (
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
CommandPresent = PluginID + ".present"
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
CommandSubmit = PluginID + ".submit"
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
ActionHelp = "builtin.help"
ActionIntent = "system.intent"
)
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
var AllowedActionIDs = map[string]struct{}{
CommandSubmit: {},
ActionHelp: {},
ActionIntent: {},
}
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
{"title":"示例页","nodes":[
{"type":"section","id":"n1","title":"结论","children":[
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
func init() {
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
}
func register(reg extension.Registry) error {
spec := json.RawMessage(fmt.Sprintf(`{
"id": %q,
"title": "生成页面",
"description": %q,
"paramsSchema": {
"type": "object",
"properties": {
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
"required": ["title", "nodes"]},
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
},
"required": ["spec"]
},
"exposeAsTool": {"risk": "low"}
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
if err := reg.AddCommandHandler(spec, present); err != nil {
return err
}
submitSpec := json.RawMessage(`{
"id": "generative-ui.submit",
"title": "提交表单",
"description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)",
"paramsSchema": {
"type": "object",
"properties": {
"surfaceId": {"type": "string"},
"revision": {"type": "integer"},
"requestId": {"type": "string"},
"data": {"type": "object"}
},
"required": ["surfaceId", "requestId", "data"]
}
}`)
return reg.AddCommandHandler(submitSpec, submitForm)
}
// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId
// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口
// 同源:审计信封与(若未来升档)高危确认卡由总线收口。
func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
surfaceID, _ := req.Params["surfaceId"].(string)
requestID, _ := req.Params["requestId"].(string)
if surfaceID == "" || requestID == "" {
return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required")
}
data, _ := json.Marshal(req.Params["data"])
key := "form:" + surfaceID + ":" + requestID
if err := h.KVPut(key, string(data)); err != nil {
return nil, err
}
if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil {
return nil, err
}
return map[string]any{"ok": true, "stored": true, "key": key}, nil
}
// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。
func ValidateActionIDs(spec []byte) []string {
var s struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(spec, &s); err != nil {
return nil
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIActions {
for _, a := range n.Actions {
if _, ok := AllowedActionIDs[a.ID]; !ok {
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
}
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
for i, c := range sd.Nodes {
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range s.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
sh, ok := h.(extension.SurfaceHost)
if !ok {
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
}
enabled, epoch, err := sh.SurfaceActivation(ctx)
if err != nil {
return nil, err
}
if !enabled {
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
}
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
surfaceID, _ := req.Params["surfaceId"].(string)
base := 0
if v, ok := req.Params["baseRevision"].(float64); ok {
base = int(v)
}
summary, _ := req.Params["summary"].(string)
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
// 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。
if errs := ValidateActionIDs(raw); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
SurfaceID: core.ID(surfaceID),
ConversationID: core.ID(req.ConversationID),
BaseRevision: base,
ObservedEpoch: epoch,
SemanticText: summary,
Spec: raw,
})
if err != nil {
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
var list []string
for _, d := range strings.Split(details, ";") {
if t := strings.TrimSpace(d); t != "" {
list = append(list, t)
}
}
return toolError("invalid spec", list), nil
}
return nil, err
}
return map[string]any{
"ok": true,
"surfaceId": string(snap.Identity.SurfaceID),
"revision": snap.Revision,
"epoch": snap.ActivationEpoch,
"summary": snap.SemanticText,
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
}, nil
}
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
type toolErrorBody struct {
OK bool `json:"ok"`
Error string `json:"error"`
Details []string `json:"details,omitempty"`
Note string `json:"note"`
}
func toolError(msg string, details []string) toolErrorBody {
return toolErrorBody{OK: false, Error: msg, Details: details,
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
}
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
// 改写,不改内容语义;修复后仍过 core 校验器。
func normalizeSpecParam(v any) any {
if arr, ok := v.([]any); ok {
return map[string]any{"title": "", "nodes": arr}
}
m, ok := v.(map[string]any)
if !ok {
return v
}
if len(m) == 1 {
if inner, ok := m["page"].(map[string]any); ok {
m = inner
}
}
if _, ok := m["nodes"]; !ok {
if ch, ok := m["children"].([]any); ok {
m["nodes"] = ch
delete(m, "children")
}
}
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
// 信息,不发明内容;节点自带 source 者不受影响。
if top, ok := m["source"].([]any); ok {
delete(m, "source")
if nodes, ok := m["nodes"].([]any); ok {
for _, n := range nodes {
if nm, ok := n.(map[string]any); ok {
if _, has := nm["source"]; !has {
cp := make([]any, len(top))
copy(cp, top)
nm["source"] = cp
}
}
}
}
}
repairChildrenShape(m)
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
m = unwrapped
}
repairPairs(m)
repairTableCells(m)
return m
}
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
// 安全解包;只改结构不改内容。
func repairItemWrappers(v any) any {
switch t := v.(type) {
case map[string]any:
if len(t) == 1 {
if inner, ok := t["item"]; ok {
return repairItemWrappers(inner)
}
}
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = repairItemWrappers(val)
}
return out
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = repairItemWrappers(item)
}
return out
default:
return v
}
}
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
// 交校验器给字段级错误供模型修复重提。
func repairTableCells(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if tbl, ok := m["table"].(map[string]any); ok {
cols, colsOK := tbl["columns"].([]any)
rows, rowsOK := tbl["rows"].([]any)
if colsOK && rowsOK {
keys := make([]string, len(cols))
valid := true
for i, c := range cols {
cm, ok := c.(map[string]any)
if !ok {
valid = false
break
}
k, ok := cm["key"].(string)
if !ok || k == "" {
valid = false
break
}
keys[i] = k
}
if valid {
for _, r := range rows {
rm, ok := r.(map[string]any)
if !ok {
continue
}
cells, ok := rm["cells"].([]any)
if !ok || len(cells) != len(keys) {
continue
}
paired := make([]any, 0, len(cells))
convertible := true
for i, c := range cells {
switch cv := c.(type) {
case string:
paired = append(paired, []any{keys[i], cv})
case float64:
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
default:
convertible = false
break
}
}
if convertible {
rm["cells"] = paired
}
}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairTableCells(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairTableCells(im)
}
}
}
}
}
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
func repairChildrenShape(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if ch, ok := m["children"]; ok && ch != nil {
switch v := ch.(type) {
case map[string]any:
arr := make([]any, 0, len(v))
for _, val := range v {
arr = append(arr, val)
}
m["children"] = arr
case []any:
for _, c := range v {
if cm, ok := c.(map[string]any); ok {
repairChildrenShape(cm)
}
}
default:
// 单对象子节点
if cm, ok := ch.(map[string]any); ok {
m["children"] = []any{cm}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairChildrenShape(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairChildrenShape(im)
}
}
}
}
}
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
func repairPairs(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
for key, val := range m {
if (key == "kv" || key == "cells") && val != nil {
if arr, ok := val.([]any); ok {
fixed := make([]any, 0, len(arr))
changed := false
for _, item := range arr {
if p, ok := pairFromObject(item); ok {
fixed = append(fixed, p)
changed = true
} else {
fixed = append(fixed, item)
}
}
if changed {
m[key] = fixed
}
}
}
repairPairs(val)
}
}
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
func pairFromObject(item any) ([2]any, bool) {
obj, ok := item.(map[string]any)
if !ok {
return [2]any{}, false
}
var k, v any
for kk, vv := range obj {
switch kk {
case "k", "key", "name":
k = vv
case "v", "value":
v = vv
default:
return [2]any{}, false
}
}
if k == nil || v == nil {
return [2]any{}, false
}
ks, ok1 := k.(string)
vs, ok2 := v.(string)
if !ok1 || !ok2 {
return [2]any{}, false
}
return [2]any{ks, vs}, true
}
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
func authorizeDocRefs(raw []byte, h extension.Host) []string {
var spec struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
return []string{"spec: " + err.Error()}
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIDocRef && n.DocID != "" {
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
for i, c := range sd.Nodes {
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range spec.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}
+385
View File
@@ -0,0 +1,385 @@
package genui
// U3 红测(提案 U3 验证行):能力缺失明确拒绝;未启用拒绝;伪造 doc-ref 拒绝;
// 目录违规返回字段级清单(供模型修复重提);成功路径经 EXT-17 提交并携带观察 epoch。
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// realSvc 让 fakeHost.SurfaceSave 走真实 surfacesvc(校验/守卫行为与生产一致)。
var realSvc *surfacesvc.Service
func newRealSvc(t *testing.T) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
realSvc = surfacesvc.New(st, nil)
if _, err := realSvc.SetActivation(context.Background(), "alice", PluginID, true); err != nil {
t.Fatal(err)
}
}
type fakeHost struct {
extension.Host // 未覆盖方法仅测试未触达
enabled bool
epoch int64
saved []extension.SurfaceSaveRequest
artifacts map[string]bool
}
func (h *fakeHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
return h.enabled, h.epoch, nil
}
func (h *fakeHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
h.saved = append(h.saved, req)
return realSvc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
SemanticText: req.SemanticText, Spec: req.Spec,
})
}
func (h *fakeHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
return realSvc.Get(ctx, "alice", surfaceID)
}
func (h *fakeHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
return realSvc.List(ctx, "alice", conversationID)
}
func (h *fakeHost) ArtifactGet(id string) (core.Artifact, error) {
if h.artifacts[id] {
return core.Artifact{ID: core.ID(id)}, nil
}
return core.Artifact{}, store.ErrNotFound
}
func validSpec() map[string]any {
return map[string]any{
"title": "选型对比",
"nodes": []any{
map[string]any{"type": "section", "id": "n1", "title": "概览", "children": []any{
map[string]any{"type": "markdown", "id": "n1a", "source": []any{"doc:a"}, "markdown": "结论先行。"},
}},
map[string]any{"type": "table", "id": "n2", "table": map[string]any{
"columns": []any{map[string]any{"key": "k", "label": "维度"}},
"rows": []any{map[string]any{"cells": []any{[]any{"k", "价格"}}}},
}},
},
}
}
func reqWith(spec map[string]any, extra ...map[string]any) extension.Request {
params := map[string]any{"spec": spec}
for _, e := range extra {
for k, v := range e {
params[k] = v
}
}
return extension.Request{ID: "r1", Params: params, Owner: "alice", ConversationID: "c1"}
}
func TestPresentGuards(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
// 能力缺失(Host 未实现 SurfaceHost)→ 明确报错
if _, err := present(ctx, plainHost{}, reqWith(validSpec())); err == nil {
t.Fatal("capability-missing host should fail explicitly")
}
// 未启用 → 拒绝
h := &fakeHost{enabled: false, artifacts: map[string]bool{}}
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
t.Fatal("disabled plugin should reject submit")
}
// 伪造 doc-ref:工件不存在 → 字段级工具错误
h = &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{"type": "doc-ref", "docId": "art_ghost"}}}
res, err := present(ctx, h, reqWith(spec))
if err != nil {
t.Fatalf("doc-ref violation should be tool-error, got %v", err)
}
body, _ := json.Marshal(res)
if !strings.Contains(string(body), "art_ghost") {
t.Fatalf("field errors should name the bad docId: %s", body)
}
// 目录违规(未知字段 color)→ 字段级清单
bad := map[string]any{"title": "x", "nodes": []any{map[string]any{"type": "list", "items": []any{"a"}, "color": "red"}}}
res, err = present(ctx, h, reqWith(bad))
if err != nil {
t.Fatalf("invalid spec should be tool-error, got %v", err)
}
te, ok := res.(toolErrorBody)
if !ok || te.OK || len(te.Details) == 0 {
t.Fatalf("expected field-level details, got %+v", res)
}
}
func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
res, err := present(ctx, h, reqWith(validSpec()))
if err != nil {
t.Fatal(err)
}
m, _ := res.(map[string]any)
if m["ok"] != true || fmt.Sprint(m["revision"]) != "1" {
t.Fatalf("submit result: %+v", m)
}
if len(h.saved) != 1 || h.saved[0].ObservedEpoch != 1 {
t.Fatalf("submit must carry observed epoch: %+v", h.saved)
}
// 修订:带 surfaceId + baseRevision → revision 2
res2, err := present(ctx, h, reqWith(validSpec(), map[string]any{
"surfaceId": m["surfaceId"], "baseRevision": float64(1)}))
if err != nil {
t.Fatal(err)
}
m2, _ := res2.(map[string]any)
if fmt.Sprint(m2["revision"]) != "2" {
t.Fatalf("revision should advance to 2: %+v", m2)
}
// 禁用后:执行端拒绝(闸外第二道守卫)
h.enabled = false
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
t.Fatal("post-disable submit should reject")
}
}
func TestRegisterSpecShape(t *testing.T) {
var captured []json.RawMessage
reg := &fakeRegistry{captureList: &captured}
if err := register(reg); err != nil {
t.Fatal(err)
}
byID := map[string]json.RawMessage{}
for _, raw := range captured {
var id struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &id); err != nil {
t.Fatal(err)
}
byID[id.ID] = raw
}
var spec struct {
ID string `json:"id"`
Description string `json:"description"`
ExposeAsTool map[string]any `json:"exposeAsTool"`
}
if raw, ok := byID[CommandPresent]; !ok {
t.Fatalf("present command not registered: %v", captured)
} else if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatal(err)
}
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
t.Fatalf("present must expose as tool: %s", byID[CommandPresent])
}
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} {
if !strings.Contains(spec.Description, need) {
t.Fatalf("catalog incomplete: missing %q", need)
}
}
var submit struct {
ExposeAsTool map[string]any `json:"exposeAsTool"`
}
if raw, ok := byID[CommandSubmit]; !ok {
t.Fatalf("submit command not registered: %v", captured)
} else {
_ = json.Unmarshal(raw, &submit)
if submit.ExposeAsTool != nil {
t.Fatal("submit must NOT expose as tool (button-only; model cannot call)")
}
}
}
type plainHost struct{ extension.Host }
type fakeRegistry struct{ captureList *[]json.RawMessage }
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
*r.captureList = append(*r.captureList, spec)
return nil
}
func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil }
func (r *fakeRegistry) AddCommand(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return nil }
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
return nil
}
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": "shell.exec", "label": "危险"},
map[string]any{"id": "generative-ui.submit", "label": "提交"},
}}}}
res, err := present(ctx, h, reqWith(spec))
if err != nil {
t.Fatalf("should be tool error, got %v", err)
}
te, ok := res.(toolErrorBody)
if !ok || te.OK || len(te.Details) == 0 {
t.Fatalf("expected field errors, got %+v", res)
}
if !strings.Contains(strings.Join(te.Details, ";"), "shell.exec") {
t.Fatalf("errors should name the unknown action: %+v", te)
}
}
func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": ActionHelp, "label": "帮助"},
map[string]any{"id": ActionIntent, "label": "意图"},
map[string]any{"id": CommandSubmit, "label": "提交"},
}}}})
if errs := ValidateActionIDs(raw); len(errs) != 0 {
t.Fatalf("B-class help/intent + submit should pass, got %v", errs)
}
}
func TestSubmitFormStoresLedger(t *testing.T) {
// C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。
var kv map[string]string = map[string]string{}
h := &ledgerHost{kv: kv}
res, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
"surfaceId": "surf_x", "requestId": "req-1", "revision": float64(1),
"data": map[string]any{"years": "5"},
}})
if err != nil {
t.Fatal(err)
}
m, _ := res.(map[string]any)
if m["ok"] != true || m["stored"] != true {
t.Fatalf("submit result: %+v", m)
}
if v, ok := kv["form:surf_x:req-1"]; !ok || !strings.Contains(v, `"years":"5"`) {
t.Fatalf("ledger missing data: %v", kv)
}
// 幂等重放同键覆盖同值
if _, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
"surfaceId": "surf_x", "requestId": "req-1", "data": map[string]any{"years": "5"},
}}); err != nil {
t.Fatal(err)
}
}
type ledgerHost struct {
extension.Host
kv map[string]string
}
func (h *ledgerHost) KVPut(key, value string) error { h.kv[key] = value; return nil }
func (h *ledgerHost) Audit(action, target, evidence string) error { return nil }
func TestNormalizeChildrenShape(t *testing.T) {
// 真实偏差(2026-10-11 dev 实测):模型把 children 写成按 key 编号的对象映射。
in := map[string]any{
"type": "section", "id": "n1", "title": "结论",
"children": map[string]any{
"item": map[string]any{"type": "markdown", "id": "m1", "markdown": "结论先行"},
},
}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
nodes := out.(map[string]any)["nodes"].([]any)
ch := nodes[0].(map[string]any)["children"].([]any)
if len(ch) != 1 || ch[0].(map[string]any)["id"] != "m1" {
t.Fatalf("children should be repaired to array, got %v", ch)
}
}
// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出
// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]},
// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的
// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。
func TestPresentRepairsItemWrappers(t *testing.T) {
newRealSvc(t)
h := &fakeHost{enabled: true, epoch: 1}
spec := map[string]any{
"title": "本月支出结构分析(示例数据演示)",
"nodes": []any{
map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"},
map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{
map[string]any{"item": []any{"月份", "2026-10"}},
map[string]any{"item": []any{"总支出", "¥10,000"}},
}}},
map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{
map[string]any{"item": "餐饮 35%"},
map[string]any{"item": "住房 25%"},
}}},
map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{
map[string]any{"key": "cat", "label": "分类"},
map[string]any{"key": "amt", "label": "金额"},
map[string]any{"key": "pct", "label": "占比"},
},
"rows": []any{
map[string]any{"cells": map[string]any{"item": []any{
map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"},
}}},
},
}},
},
}
res, err := present(context.Background(), h, reqWith(spec))
if err != nil {
t.Fatalf("present: %v", err)
}
if body, ok := res.(toolErrorBody); ok {
t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details)
}
if len(h.saved) != 1 {
t.Fatalf("expected one saved surface, got %d", len(h.saved))
}
var saved core.SurfaceSpec
if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil {
t.Fatalf("saved spec should unmarshal: %v", err)
}
if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" {
t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV)
}
if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" {
t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items)
}
cells := saved.Nodes[3].Table.Rows[0].Cells
if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" {
t.Fatalf("table cells should pair with column keys, got %v", cells)
}
}
// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。
func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) {
in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}},
"rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}},
}}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any)
got := rows[0].(map[string]any)["cells"].([]any)
if len(got) != 3 || got[0] != "x" {
t.Fatalf("mismatched scalars should stay untouched, got %v", got)
}
}
+251
View File
@@ -0,0 +1,251 @@
package genui
// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→
// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm
// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。
//
// 启用:GENUI_MINIMAX_API_KEY=<key> go test ./internal/genui/ -run TestRealModelSurfaceLoop -v -count=1
// 未设 env 时跳过(CI 无凭据;本地收据经 make evidence 记录)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
const materials = `材料:
【doc:m1】甲方案:报价 ¥8/人/天;私有化可选;近半年发布 6 次,平均修复 2 天。
【doc:m2】乙方案:报价 ¥12/人/天;仅 SaaS;万行表格场景无官方基准;升级断层(近两季仅小版本)。
【doc:m3】尽调备忘:合规缺口(高)——数据出境条款未覆盖;培训成本(低)——术语不一致。`
type chatResp struct {
Choices []struct {
Message struct {
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
func callModelWithRepair(t *testing.T, ctx context.Context, system, user string, try func(map[string]any) (any, error)) (map[string]any, any, chatResp) {
t.Helper()
var usage chatResp
params, r := callModel(t, ctx, system, user)
usage.Usage.PromptTokens += r.Usage.PromptTokens
usage.Usage.CompletionTokens += r.Usage.CompletionTokens
res, err := try(params)
if err == nil {
if te, ok := res.(toolErrorBody); !ok || te.OK {
return params, res, usage
} else {
res = te
}
} else if res == nil {
t.Fatalf("dispatch error: %v", err)
}
// 修复轮:字段级错误回传,模型重提一次(KTD2)。
fixUser := user + "\n\n上一次提交被拒绝:" + fmt.Sprint(res) + "\n请修正后重新调用工具。"
params2, r2b := callModel(t, ctx, system, fixUser)
usage.Usage.PromptTokens += r2b.Usage.PromptTokens
usage.Usage.CompletionTokens += r2b.Usage.CompletionTokens
res2, err2 := try(params2)
if err2 != nil {
t.Fatalf("repair dispatch error: %v", err2)
}
if te, ok := res2.(toolErrorBody); ok && !te.OK {
t.Fatalf("repair still invalid: %+v", te)
}
return params2, res2, usage
}
func callModel(t *testing.T, ctx context.Context, system, user string) (map[string]any, chatResp) {
t.Helper()
key := os.Getenv("GENUI_MINIMAX_API_KEY")
tool := map[string]any{"type": "function", "function": map[string]any{
"name": CommandPresent, "description": Catalog, "parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"spec": map[string]any{"type": "object"},
"surfaceId": map[string]any{"type": "string"},
"baseRevision": map[string]any{"type": "integer"},
"summary": map[string]any{"type": "string"},
},
"required": []string{"spec"},
}}}
body, _ := json.Marshal(map[string]any{
"model": "MiniMax-M3", "temperature": 0.3, "max_tokens": 14000,
"tools": []any{tool}, "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": CommandPresent}},
"messages": []any{
map[string]any{"role": "system", "content": system},
map[string]any{"role": "user", "content": user},
}})
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.minimaxi.com/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
var out chatResp
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("model call: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Choices) == 0 || len(out.Choices[0].Message.ToolCalls) == 0 {
t.Fatalf("no tool call in response")
}
var params map[string]any
if err := json.Unmarshal([]byte(out.Choices[0].Message.ToolCalls[0].Function.Arguments), &params); err != nil {
t.Fatalf("tool args: %v", err)
}
return params, out
}
func TestRealModelSurfaceLoop(t *testing.T) {
if os.Getenv("GENUI_MINIMAX_API_KEY") == "" {
t.Skip("GENUI_MINIMAX_API_KEY not set; real-model replay runs locally with recorded receipts")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "replay.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
svc := surfacesvc.New(st, nil)
if _, err := svc.SetActivation(ctx, "alice", PluginID, true); err != nil {
t.Fatal(err)
}
h := &realHost{svc: svc, artifacts: map[string]bool{}}
// 回合 1:任务→规格→保存
sys1 := "你是本产品中的助手。用户需要一页比较视图时,用 generative-ui.present 提交页面。"
try1 := func(p map[string]any) (any, error) {
return present(ctx, h, extension.Request{ID: "t1", Params: p, Owner: "alice", ConversationID: "c1"})
}
_, res1, r1 := callModelWithRepair(t, ctx, sys1, materials+"\n\n目标:给决策会准备风险分析页(按严重度组织,标注来源)。", try1)
m1, _ := res1.(map[string]any)
if m1["ok"] != true {
t.Fatalf("turn1 not accepted: %+v", res1)
}
snap1, err := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
if err != nil {
t.Fatal(err)
}
t.Logf("turn1: surface=%s revision=%d nodes=%d tokens=%d/%d",
m1["surfaceId"], snap1.Revision, countNodes(snap1), r1.Usage.PromptTokens, r1.Usage.CompletionTokens)
// 回合 2:语义投影回灌 + 修订指令 → 新修订
msgs2, _ := ctxasm.AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改为实施顺序"}}},
nil, nil, nil, nil, nil, nil, ctxasm.AssembleOptions{Surfaces: []core.SurfaceSnapshot{snap1}})
var sysParts []string
for _, m := range msgs2 {
if string(m.Role) == "system" {
sysParts = append(sysParts, m.Content)
}
}
sys2 := strings.Join(sysParts, "\n")
try2 := func(p map[string]any) (any, error) {
p["surfaceId"] = m1["surfaceId"]
p["baseRevision"] = float64(snap1.Revision)
return present(ctx, h, extension.Request{ID: "t2", Params: p, Owner: "alice", ConversationID: "c1"})
}
_, res2, r2 := callModelWithRepair(t, ctx, sys1+"\n"+sys2, materials+"\n\n目标:改为实施顺序(阶段与依赖),修订已有页面。", try2)
m2, _ := res2.(map[string]any)
if fmt.Sprint(m2["revision"]) != fmt.Sprint(snap1.Revision+1) {
t.Fatalf("turn2 should revise to %d, got %+v", snap1.Revision+1, res2)
}
snap2, _ := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
types2 := dominantTypes(snap2)
same := len(types2) == len(snap1.Spec.Nodes)
if same {
for i := range types2 {
if types2[i] != string(snap1.Spec.Nodes[i].Type) {
same = false
break
}
}
}
if len(snap2.Spec.Nodes) == 0 || (snap2.Spec.Title == snap1.Spec.Title && same) {
t.Fatalf("turn2 structure did not change: %v", types2)
}
t.Logf("turn2: revision=%d title=%q types=%v tokens=%d/%d",
snap2.Revision, snap2.Spec.Title, types2, r2.Usage.PromptTokens, r2.Usage.CompletionTokens)
totalIn := r1.Usage.PromptTokens + r2.Usage.PromptTokens
totalOut := r1.Usage.CompletionTokens + r2.Usage.CompletionTokens
cost := float64(totalIn)/1e6*0.3*7.2 + float64(totalOut)/1e6*1.2*7.2
t.Logf("cost: in=%d out=%d ≈¥%.3f (M3 $0.3/$1.2 per M, 7.2)", totalIn, totalOut, cost)
}
type realHost struct {
extension.Host
svc *surfacesvc.Service
artifacts map[string]bool
}
func (h *realHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
row, err := h.svc.Activation(ctx, "alice", PluginID)
return row.Enabled, row.Epoch, err
}
func (h *realHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
return h.svc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, BaseRevision: req.BaseRevision,
ObservedEpoch: req.ObservedEpoch, SemanticText: req.SemanticText, Spec: req.Spec})
}
func (h *realHost) SurfaceGet(ctx context.Context, id core.ID) (core.SurfaceSnapshot, error) {
return h.svc.Get(ctx, "alice", id)
}
func (h *realHost) SurfaceList(ctx context.Context, cid core.ID) ([]core.SurfaceMeta, error) {
return h.svc.List(ctx, "alice", cid)
}
func (h *realHost) ArtifactGet(id string) (core.Artifact, error) {
if h.artifacts[id] {
return core.Artifact{ID: core.ID(id)}, nil
}
return core.Artifact{}, store.ErrNotFound
}
func countNodes(s core.SurfaceSnapshot) int {
var n int
var walk func(u core.UINode)
walk = func(u core.UINode) {
n++
for _, c := range u.Children {
walk(c)
}
}
for _, x := range s.Spec.Nodes {
walk(x)
}
return n
}
func dominantTypes(s core.SurfaceSnapshot) []string {
var out []string
for _, n := range s.Spec.Nodes {
out = append(out, string(n.Type))
}
return out
}
+130 -17
View File
@@ -1,8 +1,14 @@
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3):
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3 / 协作存储 U6 切片一):
// 落盘事务提交后 NOTIFY agent_events '<conv>|<lastSeq>';每实例一个 LISTEN 连接,
// 收到通知按 seq 从库取回事件并转发本地 bus(SSE 以 seq 去重,双投无害)。
// 收到通知按会话游标从库分页追赶转发本地 bus(SSE 以 seq 去重,重投无害)。
// 多实例部署契约:run 在其归属实例内存中执行,会话流量需按会话亲和路由
// (网关/客户端择点);本包只解决“其他实例的订阅者”可见性。
//
// U6(KTD6)修复的两个既有缺口:
// - 批内遗漏:通知只带末 seq,旧实现 EventAt 只取末条——批内中间事件对
// 实时订阅者不可见。现按会话游标 EventsAfter 分页追赶,末 seq 即水位;
// - 断线假重连:旧实现对原连接继续 WaitForNotification。现真正重建
// 连接→LISTEN→按全部已知会话追赶,另有周期补查覆盖通知丢失。
package pgbus
import (
@@ -10,6 +16,7 @@ import (
"log"
"strconv"
"strings"
"sync"
"time"
"agent-system-backend/internal/bus"
@@ -21,6 +28,12 @@ import (
const channel = "agent_events"
// catchUpPage 追赶分页大小(验收含 1000+ 事件分页可见)。
const catchUpPage = 500
// rescueEvery 周期补查间隔:覆盖 NOTIFY 丢失与监听窗口前后竞争(KTD6)。
const rescueEvery = 2 * time.Second
// Notifier 落盘后通知(任意连接池连接可用)。
type Notifier struct{ st *store.Store }
@@ -39,53 +52,153 @@ func (n *Notifier) Notify(conversationID string, evs []core.StreamEvent) {
}
}
// Listener 每实例一个:LISTEN → 按 seq 取回 → 本地 bus 转发。
// Listener 每实例一个:LISTEN → 按会话游标追赶 → 本地 bus 转发。
type Listener struct {
conn *pgx.Conn
dsn string
st *store.Store
bus *bus.Bus
done chan struct{}
mu sync.Mutex
cursor map[string]int64 // conv → 已转发水位(重启清零:SSE 侧重放兜底)
}
// Start 建立监听连接并开始转发循环;返回后即可用,随 ctx 取消停机。
func Start(ctx context.Context, dsn string, st *store.Store, b *bus.Bus) (*Listener, error) {
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
l := &Listener{dsn: dsn, st: st, bus: b, done: make(chan struct{}), cursor: map[string]int64{}}
if err := l.connect(ctx); err != nil {
return nil, err
}
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
conn.Close(ctx)
return nil, err
}
l := &Listener{conn: conn, st: st, bus: b, done: make(chan struct{})}
go l.loop(ctx)
return l, nil
}
func (l *Listener) connect(ctx context.Context) error {
conn, err := pgx.Connect(ctx, l.dsn)
if err != nil {
return err
}
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
conn.Close(ctx)
return err
}
l.conn = conn
return nil
}
func (l *Listener) loop(ctx context.Context) {
defer close(l.done)
// 周期补查独立 goroutine:通知丢失场景恰恰没有通知可触发——不能只在
// 收到通知后顺带补查(KTD6)。
go l.rescueLoop(ctx)
for {
n, err := l.conn.WaitForNotification(ctx)
if err != nil {
if ctx.Err() != nil {
return // 停机
}
log.Printf("[pgbus] wait: %v(1s 后重连)", err)
time.Sleep(time.Second)
// 真重连(U6):旧实现对原连接继续 Wait——断线后永不恢复。
// 退避重建 → LISTEN → 按已知会话追赶(补断线窗口内落盘的事件)。
log.Printf("[pgbus] wait: %v(重连中)", err)
l.reconnect(ctx)
continue
}
conv, seq, ok := parsePayload(n.Payload)
if !ok {
continue
}
ev, err := l.st.EventAt(conv, seq)
if err != nil {
continue // 事件不可见(极早期窗口)——SSE 重放兜底
}
l.bus.Publish(ev)
l.catchUp(conv, seq)
}
}
// rescueLoop 周期补查心跳:随 ctx 或主循环退出(done)停止。
func (l *Listener) rescueLoop(ctx context.Context) {
t := time.NewTicker(rescueEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-l.done:
return
case <-t.C:
l.rescueKnown()
}
}
}
// reconnect 重建监听连接并立即追赶全部已知会话(有界退避重试)。
func (l *Listener) reconnect(ctx context.Context) {
_ = l.conn.Close(context.Background())
for attempt := 0; attempt < 3; attempt++ {
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := l.connect(cctx)
cancel()
if err == nil {
l.rescueKnown()
return
}
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(attempt+1) * time.Second):
}
}
}
// rescueKnown 周期补查:不经通知,按各会话游标继续分页追赶(覆盖 NOTIFY
// 丢失、监听生效窗口前后竞争——KTD6「只有落库事件才是重放依据」)。
// 调用方:重连成功后与 loop 的 rescue 心跳(消息稀疏时由 ticker 兜底)。
func (l *Listener) rescueKnown() {
l.mu.Lock()
convs := make([]string, 0, len(l.cursor))
for c := range l.cursor {
convs = append(convs, c)
}
l.mu.Unlock()
for _, conv := range convs {
l.catchUp(conv, 1<<62)
}
}
// catchUp 从会话游标之后分页取回并转发,直到取空或越过水位 toSeq。
// 首见会话从 0 开始(SSE 端按 seq 去重,重投无害;这保证批内中间事件可见)。
func (l *Listener) catchUp(conv string, toSeq int64) {
l.mu.Lock()
since := l.cursor[conv]
l.mu.Unlock()
for {
evs, err := l.st.EventsAfter(conv, since, catchUpPage)
if err != nil {
log.Printf("[pgbus] catchup %s@%d: %v", conv, since, err)
return
}
stop := false
for _, ev := range evs {
if ev.Seq > toSeq {
stop = true
break
}
l.bus.Publish(ev)
since = ev.Seq
}
l.setCursor(conv, since)
if stop || len(evs) < catchUpPage {
return
}
}
}
func (l *Listener) setCursor(conv string, seq int64) {
l.mu.Lock()
if seq > l.cursor[conv] {
l.cursor[conv] = seq
}
l.mu.Unlock()
}
// parsePayload 拆 '<conv>|<seq>'(会话 ID 不含 '|')。
func parsePayload(p string) (string, int64, bool) {
i := strings.LastIndex(p, "|")
if i < 0 {
+161 -1
View File
@@ -29,6 +29,12 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
if err != nil {
t.Fatal(err)
}
// pg_trgm 预装 public(幂等),理由见 store 包 openTestStore 注释:
// 避免扩展落进用例 schema 后随 CASCADE 删除造成的并行竞态。
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatal(err)
@@ -37,7 +43,7 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
full := dsn + "&search_path=" + schema
full := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
st, err := store.OpenPostgres(full, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
@@ -80,3 +86,157 @@ func TestNotifyListenerFanout(t *testing.T) {
t.Fatal("no fanout within 3s")
}
}
// ---- U6 切片一(KTD6):批内全可见、分页追赶、断线重连、通知丢失补查 ----
// drainUntil 从订阅通道收集事件直到谓词为真或超时;返回收到的全部事件。
func drainUntil(ch <-chan core.StreamEvent, done func([]core.StreamEvent) bool, timeout time.Duration) []core.StreamEvent {
var got []core.StreamEvent
deadline := time.After(timeout)
for {
select {
case e := <-ch:
got = append(got, e)
if done(got) {
return got
}
case <-deadline:
return got
}
}
}
// 一批 64 条事件(单条 NOTIFY 只带末 seq)对实时订阅者全部可见——
// 旧实现 EventAt 只取末条,批内 1..63 全部遗漏。
func TestBatchEventsAllVisible(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_batch")
defer stop()
notifier := NewNotifier(st)
evs := make([]core.StreamEvent, 64)
for i := range evs {
evs[i] = core.StreamEvent{ConversationID: "conv_batch", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}
}
if _, err := st.AppendEvents("conv_batch", evs); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_batch", evs)
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 64 }, 5*time.Second)
if len(got) != 64 {
t.Fatalf("批 64 条应全部可见,收到 %d(旧实现只末条可见)", len(got))
}
// 序号连续(首见会话从 0 追赶,SSE 去重语义下的完整性)。
for i, e := range got {
if e.Seq != int64(i+1) {
t.Fatalf("序号不连续:got[%d].Seq=%d", i, e.Seq)
}
}
}
// 1200 条(跨 500/页 的三页)分页追赶全部可见。
func TestPagedCatchUpBeyondOnePage(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_page")
defer stop()
notifier := NewNotifier(st)
const total = 1200
evs := make([]core.StreamEvent, 0, total)
for i := 0; i < total; i++ {
evs = append(evs, core.StreamEvent{ConversationID: "conv_page", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)})
}
if _, err := st.AppendEvents("conv_page", evs); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_page", evs)
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= total }, 15*time.Second)
if len(got) != total {
t.Fatalf("1200 条应分页全部可见,收到 %d", len(got))
}
}
// 断线重连:底层连接被杀后,新落盘+通知的事件仍可达(真重连路径)。
func TestListenerReconnectsAfterConnLoss(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_re")
defer stop()
notifier := NewNotifier(st)
first := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_re", first); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_re", first)
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
// 杀掉监听连接(模拟网络断开;旧实现会对此连接永远 Wait 下去)。
if err := listener.conn.Close(context.Background()); err != nil {
t.Logf("close conn: %v", err)
}
// 断线窗口内落盘一批(重连后应经追赶补上)。
during := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_re", during); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_re", during)
// 第二段 drain 从零计数:断线窗口内的一条事件到达即证明重连+追赶生效。
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 10*time.Second)
if len(got) < 1 {
t.Fatalf("重连后应补见断线窗口内事件:got=%d", len(got))
}
}
// 通知丢失补查:事件落盘但**不**发 NOTIFY,周期补查应在 rescueEvery 内送达。
func TestRescueCoversLostNotifications(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_lost")
defer stop()
// 先用一次正常通知建立该会话的游标(补查只覆盖已知会话)。
notifier := NewNotifier(st)
seed := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_lost", seed); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_lost", seed)
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
// 只落盘、不通知(模拟 NOTIFY 丢失)。
lost := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_lost", lost); err != nil {
t.Fatal(err)
}
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*rescueEvery+2*time.Second)
if len(got) < 1 {
t.Fatalf("周期补查应送达无通知事件:got=%d", len(got))
}
}
@@ -1,7 +1,7 @@
---
id: agent.sub.explore
layer: agent
version: 1
version: 2
title: 探索子代理(只读)
description: 只读检索专家——定位文件/内容/事实,不做任何修改
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
@@ -9,6 +9,7 @@ model: lightweight
---
你是只读探索子代理,职责是高效且彻底地定位目标信息。
- 汇报发现时给出定位(文件/页/段落);数字必须来自材料原文并注明出处,没有出处的不要写。
- 严格只读:不写入、不修改、不删除任何文件或数据;只执行检索与读取类操作。
- 检索策略:从任务给出的线索出发,多角度查询(不同关键词、不同路径);一次不中就换查询,不重复同一查询。
- 返回结论时给出定位(文件/路径/段落/链接),让派生方能直接复核。
@@ -1,14 +1,16 @@
---
id: agent.sub.general
layer: agent
version: 1
version: 3
title: 通用子代理
description: 独立完成子任务并返回结构化结果(默认子代理提示词)
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, generative-ui.present
model: lightweight
---
你是被派生的子代理,负责独立完成一个子任务。
- 结论中的关键数字与事实附来源定位(材料/文件与页/段落);没有出处的数字不要写,无法回溯时说明来源缺失。
- 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。
- 只使用与任务相关的信息,不臆测派生方未提供的上下文。
- 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。
- 页面化产物(分析页/对比页等)用 `generative-ui.present` 提交;工具不可用或提交被拒时改用 `write_artifact` 落盘并在结果中说明。
@@ -1,7 +1,7 @@
---
id: agent.sub.research
layer: agent
version: 1
version: 3
title: 调研子代理
description: 围绕问题多源调研,输出带依据的结论
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
@@ -9,6 +9,7 @@ model: lightweight
---
你是调研子代理,围绕给定问题收集信息并给出结论。
- 结论里的关键数字与事实必须附原文引用(材料/文件与页/段落定位,重要处引原句);没有出处的数字不要写,无法回溯时明确说明「来源缺失」而不是估一个。检索/读取结果带【src_N|工具】标记时,引用同时给出该标记编号。
- 先把问题拆成可回答的子问题,再逐一检索;优先项目内知识库与已给材料,其次按任务指定的外部来源。
- 每条结论标注依据来源;多来源冲突时不和稀泥,列出各方说法与差异点,说明你采信哪个及理由。
- 覆盖正反两面:支持与不支持该结论的证据都要呈现。
@@ -1,7 +1,7 @@
---
id: base.deliverables
layer: base
version: 5
version: 7
title: 交付物工作流
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
---
@@ -16,6 +16,8 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
检索与读取的工具结果末尾带【src_N|工具名】来源标记:成文时数字与关键事实的出处就是这些带标记的结果,逐项核对;交付物里的每一个百分比、日期和大额数字,都必须能在本轮上传材料或你自己的工具/计算输出里找到出处;找不到出处的,要么删除,要么用「目标 / 预计 / 假设」等词在文中显式标注为规划量(如「目标 85%」「预计三个月」)。终轮会对无出处且未标注的数字拦截两轮;仍无法回溯又拒绝标注的,会被系统如实记为未核实。
没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。工具只能通过函数调用触发,不要在回复里书写 `[调用工具 …]`、`[工具结果 …]`、`[产出工件 …]` 这类文字。
交付物族(所有任务共用,不限于会议):
+1
View File
@@ -12,5 +12,6 @@ description: 把任务做完的方法论:计划、换路、查 API、坚持到
- **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。
- **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
- **重任务走快慢双速**:预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理、成套材料整理),先用当前回复给用户即时反馈——材料的相关性判断、概要或执行计划——再用 delegate_task 的 background=true 把深度工作放到后台执行;后台进度与结果会进收件箱卡片,完成后系统自动汇报。简单任务仍同步做完,不要为快而快把小事也丢后台。
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
+6
View File
@@ -48,6 +48,12 @@ type embeddingsResponse struct {
} `json:"error,omitempty"`
}
// Model 当前 embedding 模型身份(U6-3:写入与检索同源;未配置返回空)。
func (e *HTTPEmbedder) Model() string {
route := e.resolve(context.Background())
return route.Model
}
func (e *HTTPEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
+3
View File
@@ -13,6 +13,9 @@ const hashDim = 32
// 由 settings models.embedding.provider=hash 显式打开;未设置时仍按 D23 退化为全文。
type HashEmbedder struct{}
// Model 本地哈希嵌入的身份(U6-3:同源写入/检索,禁止与真实模型混查)。
func (HashEmbedder) Model() string { return "hash:" + fmt.Sprint(hashDim) }
func (HashEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, text := range texts {
+21 -3
View File
@@ -2,6 +2,8 @@ package rag
import (
"context"
"crypto/sha256"
"encoding/hex"
"log"
"agent-system-backend/internal/settings"
@@ -104,17 +106,33 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
return nil
}
model := embedderModel(p.embedder)
for i, v := range vecs {
if len(v) == 0 {
continue
}
if err := p.st.UpsertKnowledgeVector(ctx, owner, docs[i].ID, docs[i].Ref, v); err != nil {
if err := p.st.UpsertKnowledgeVectorExt(ctx, owner, docs[i].ID, docs[i].Ref, model, contentHash(docs[i].Chunk), v); err != nil {
return err
}
}
return nil
}
// embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧
// 声明;未声明=空=legacy 无身份行,向后兼容)。
func embedderModel(e core.EmbeddingProvider) string {
if m, ok := e.(interface{ Model() string }); ok {
return m.Model()
}
return ""
}
// contentHash 分块内容摘要(写入幂等核对用;sha256 前 16 字节十六进制)。
func contentHash(chunk string) string {
sum := sha256.Sum256([]byte(chunk))
return hex.EncodeToString(sum[:])[:16]
}
func firstRef(docs []core.KnowledgeDoc) string {
for _, d := range docs {
if d.Ref != "" {
@@ -143,7 +161,7 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
if topK <= 0 {
topK = 5
}
vs, e2 := p.st.SearchKnowledgeVectorsShared(ctx, string(owner), qvecs[0], topK, q, allow)
vs, e2 := p.st.SearchKnowledgeVectorsSharedModel(ctx, string(owner), embedderModel(p.embedder), qvecs[0], topK, q, allow)
if e2 == nil {
shared = rrfMerge(vs, shared)
}
@@ -179,7 +197,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
return p.st.SearchKnowledge(owner, q)
}
q.TopK = topK * 2 // 每路多取,融合后再裁剪
vhits, err := p.st.SearchKnowledgeVectors(ctx, owner, qvecs[0], topK*2, q)
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
if err != nil {
return nil, err
}
+22 -7
View File
@@ -6,6 +6,7 @@ package ratelimit
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
@@ -52,14 +53,26 @@ func (m *Memory) Allow(_ context.Context, key string, limit int, window time.Dur
return true
}
// ---- Redis 固定窗口(INCR + EXPIRE;首击设窗)----
// ---- Redis 固定窗口(Lua 原子 INCR+PEXPIRE;故障降级进程内保守限流)----
// incrExpire 原子计数并只在首击设窗(协作存储 U3,KTD10):两步 INCR/EXPIRE
// 在 EXPIRE 失败或进程于其间退出时会留下永不过期的键;Lua 单脚本消除该窗口。
var incrExpire = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return n
`)
type Redis struct {
rdb *redis.Client
// fallback:Redis 故障时的进程内保守限流(同限值、按实例计数——是共享
// 限流的近似而非替代;昂贵入口的硬保护在 PG admission,不依赖本层)。
fallback *Memory
degraded atomic.Int64
}
func NewRedis(addr, password string, db int) *Redis {
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})}
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db}), fallback: NewMemory()}
}
func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
@@ -67,16 +80,18 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Du
return true
}
rkey := "rl:" + key + ":" + time.Now().UTC().Truncate(window).Format("150405")
n, err := r.rdb.Incr(ctx, rkey).Result()
n, err := incrExpire.Run(ctx, r.rdb, []string{rkey}, window.Milliseconds()).Int64()
if err != nil {
return true // Redis 故障不阻断请求(限流是柔性行为,放行并记日志于调用方)
}
if n == 1 {
r.rdb.Expire(ctx, rkey, window+time.Second)
// 降级(KTD10):不再无界放行;按实例保守限流并计数供观测告警。
r.degraded.Add(1)
return r.fallback.Allow(ctx, key, limit, window)
}
return n <= int64(limit)
}
// Degraded 返回累计降级次数(观测:非零说明 Redis 不可用,需运维介入)。
func (r *Redis) Degraded() int64 { return r.degraded.Load() }
func (r *Redis) Ping(ctx context.Context) error { return r.rdb.Ping(ctx).Err() }
func (r *Redis) Close() error { return r.rdb.Close() }
+47
View File
@@ -55,3 +55,50 @@ func TestRedisWindow(t *testing.T) {
t.Fatal("3rd request must be limited")
}
}
// Lua 原子窗口(U3,KTD10):计数与设窗单脚本完成,键必然带 TTL——
// 窗口翻转后重新放行(原子性回归:旧两步实现窗口语义相同,此处锚定
// 「INCR 与 PEXPIRE 不可分」的行为面)。
func TestRedisWindowAtomicScript(t *testing.T) {
addr := os.Getenv("AGENT_REDIS_TEST_ADDR")
if addr == "" {
t.Skip("AGENT_REDIS_TEST_ADDR not set")
}
r := NewRedis(addr, "", 15)
defer r.Close()
ctx := context.Background()
key := "atomic-" + time.Now().Format("150405.000000000")
window := 300 * time.Millisecond
for i := 1; i <= 3; i++ {
if !r.Allow(ctx, key, 3, window) {
t.Fatalf("request %d should pass", i)
}
}
if r.Allow(ctx, key, 3, window) {
t.Fatal("4th request must be limited")
}
time.Sleep(window + 50*time.Millisecond)
if !r.Allow(ctx, key, 3, window) {
t.Fatal("new window must allow again")
}
}
// Redis 故障降级(KTD10):不可达时不再无界放行——按实例保守限流;
// Degraded 计数供观测告警。
func TestRedisDegradedFallback(t *testing.T) {
r := NewRedis("127.0.0.1:1", "", 15) // 不可达端口
defer r.Close()
ctx := context.Background()
key := "degraded-" + time.Now().Format("150405.000000000")
for i := 1; i <= 2; i++ {
if !r.Allow(ctx, key, 2, time.Minute) {
t.Fatalf("degraded request %d should pass (local limit 2)", i)
}
}
if r.Allow(ctx, key, 2, time.Minute) {
t.Fatal("degraded 3rd request must be limited locally")
}
if r.Degraded() == 0 {
t.Fatal("degradation must be observable")
}
}
+122
View File
@@ -0,0 +1,122 @@
package runmgr
import (
"context"
"errors"
"log"
"sync"
"agent-system-backend/internal/store"
)
// 协作存储 U3 切片 2:执行许可门(R8)。预算经 budgetFn 动态读取(设置
// runtime.execBudgetGlobal / runtime.execBudgetPerUser,默认全零=不限,本地档
// 行为与历史一致)。许可的共享权威在 store.admission_leases(PG 咨询锁下
// 原子清点);本门只负责有界排队(接纳数)与让出/重取(父等待子代理或
// HITL 时让出执行许可、保留会话所有权——避免额度占尽时父子互等)。
// ErrAdmissionQueueFull 待执行队列已满(接纳被拒;HTTP 层映射 429)。
var ErrAdmissionQueueFull = errors.New("runmgr: execution queue full")
// admissionGate 每进程一个;释放时广播唤醒全部等待者(无公平队列,Golang
// cond 语义;公平分配留 U7 压测后按实测加)。
type admissionGate struct {
mu sync.Mutex
broadcast chan struct{} // 释放时 close+重建
waiters int
budgetFn func() store.ExecutionBudget
queueMax int
}
func newAdmissionGate(budgetFn func() store.ExecutionBudget, queueMax int) *admissionGate {
if budgetFn == nil {
budgetFn = func() store.ExecutionBudget { return store.ExecutionBudget{} }
}
if queueMax <= 0 {
queueMax = 200 // 方案容量合同的默认队列上限(待实测调整)
}
return &admissionGate{broadcast: make(chan struct{}), budgetFn: budgetFn, queueMax: queueMax}
}
// budget 加锁读取当前预算(与 SetExecutionBudgetFn 并发安全)。
func (g *admissionGate) budget() store.ExecutionBudget {
if g == nil {
return store.ExecutionBudget{} // 直接构造的 Manager(测试):预算关闭
}
g.mu.Lock()
defer g.mu.Unlock()
return g.budgetFn()
}
// QueueDepth 返回当前排队数(诊断/验收)。
func (g *admissionGate) QueueDepth() int {
if g == nil {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
return g.waiters
}
// QueueFull 非阻塞探测:排队是否已满(API 边缘在落库前拒绝,429 且不写数据)。
func (g *admissionGate) QueueFull() bool {
budget := g.budget()
if budget.Off() {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
return g.waiters >= g.queueMax
}
// acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error {
if g == nil {
return nil
}
budget := g.budget()
if budget.Off() {
return nil
}
for {
if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil {
return nil
} else if !errors.Is(err, store.ErrBudgetExceeded) {
return err // 存储故障:不放行(fail-closed,宁可拒绝执行)
}
g.mu.Lock()
if g.waiters >= g.queueMax {
g.mu.Unlock()
return ErrAdmissionQueueFull
}
g.waiters++
ch := g.broadcast
g.mu.Unlock()
select {
case <-ch:
g.mu.Lock()
g.waiters--
g.mu.Unlock()
case <-ctx.Done():
g.mu.Lock()
g.waiters--
g.mu.Unlock()
return ctx.Err()
}
}
}
// release 释放执行许可并广播(幂等;预算关闭时零开销)。
func (g *admissionGate) release(st *store.Store, runID string) {
if g == nil || g.budget().Off() {
return
}
if err := st.ReleaseExecutionSlot(runID); err != nil {
// 释放失败只影响账本整洁(下次复活即修正),不中断运行。
log.Printf("[runmgr] admission release %s: %v", runID, err)
}
g.mu.Lock()
close(g.broadcast)
g.broadcast = make(chan struct{})
g.mu.Unlock()
}
+140
View File
@@ -0,0 +1,140 @@
package runmgr
import (
"context"
"errors"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 协作存储 U3 切片 2(R8):执行预算在 runmgr 侧的行为面——
// 子代理计入全局预算、父让出避免父子互等、队列有界(429 拒绝面)。
// 同步子代理:父占满全局预算仍能派生前进(父让出 → 子获取 → 子完成 →
// 父重取),不发生互等死锁。
func TestSyncSpawnUnderSaturatedBudgetMakesProgress(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "子结果"}}}
c, st := newSpawnEnv(t, runner)
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
c.mgr.SetExecutionBudgetFn(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 10)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
// 父先占住唯一的执行许可(等价于一个正在执行的父 run)。
if err := st.TryAcquireExecutionSlot(string(parentRun.ID), parentRun.Owner, store.ExecutionBudget{Global: 1}); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
_, err := c.mgr.spawnSubagent(context.Background(), parentRun, "agent.sub.explore", "检索", "任务", "")
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("饱和预算下同步派生应前进:%v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("父满额派生死锁:子代理 10s 未完成(让出语义失效)")
}
// 父重取成功:租约复活。
if held, _, err := st.ActiveExecutionSlots(); err != nil || held != 1 {
t.Fatalf("父应重取唯一许可:held=%d err=%v", held, err)
}
}
// 后台子代理计入全局预算:预算 1 时第二个后台子代理排队,第一个完成后
// 才执行(不超卖)。
func TestBackgroundChildrenShareBudget(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
runner := &gateRunner{entered: entered, release: release, parts: []core.Part{{Type: core.PartText, Text: "结果"}}}
c, st := newSpawnEnv(t, runner)
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
c.mgr.SetExecutionBudgetFn(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 10)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
first, err := c.mgr.spawnBackground(parentRun, "agent.sub.explore", "A", "任务A", "")
if err != nil {
t.Fatal(err)
}
<-entered // 第一个子代理已进入执行(持有唯一许可)。
second, err := c.mgr.spawnBackground(parentRun, "agent.sub.explore", "B", "任务B", "")
if err != nil {
t.Fatal(err)
}
// 第二个应处于排队(无活跃租约可用)——账本里只有 first。
if held, _, _ := st.ActiveExecutionSlots(); held != 1 {
t.Fatalf("预算 1 时不应有两个活跃许可:%d", held)
}
close(release)
// 第一个完成后第二个获得许可并执行完毕(entered 的 once 已用掉,用
// 账本最终归零 + 等待窗口断言)。
deadline := time.Now().Add(10 * time.Second)
for {
held, _, _ := st.ActiveExecutionSlots()
if held == 0 && c.mgr.QueueDepth() == 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("第二个后台子代理未完成:held=%d queue=%d", held, c.mgr.QueueDepth())
}
time.Sleep(50 * time.Millisecond)
}
_ = first
_ = second
}
// 队列有界:预算占满、队列达上限后 QueueFull() 为真(API 边缘 429 拒绝面),
// acquire 返回 ErrAdmissionQueueFull;释放后排队者获得许可。
func TestAdmissionQueueBounded(t *testing.T) {
st := openTestStoreQuiet(t)
g := newAdmissionGate(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 1)
ctx := context.Background()
if err := g.acquire(ctx, st, "run_hold", "o"); err != nil {
t.Fatal(err)
}
queued := make(chan error, 1)
go func() { queued <- g.acquire(ctx, st, "run_wait", "o") }()
// 等待排队者入队(深度 1)。
deadline := time.Now().Add(5 * time.Second)
for g.QueueDepth() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !g.QueueFull() {
t.Fatal("队列达上限应 QueueFull")
}
if err := g.acquire(ctx, st, "run_over", "o"); !errors.Is(err, ErrAdmissionQueueFull) {
t.Fatalf("超队列应 ErrAdmissionQueueFull:%v", err)
}
g.release(st, "run_hold")
if err := <-queued; err != nil {
t.Fatalf("排队者应获得许可:%v", err)
}
g.release(st, "run_wait")
}
// openTestStoreQuiet:runmgr 测试包里建一个不迁移断言的临时库(沿用
// recovery_test 的 openRecoveryStore 会走双档;这里只需本地档)。
func openTestStoreQuiet(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(t.TempDir() + "/adm.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
var _ = core.Run{} // 保持 import 锚(newSpawnEnv 依赖 core)
+164
View File
@@ -0,0 +1,164 @@
package runmgr
import (
"errors"
"strings"
"sync"
"testing"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
// newCardSpawnEnv 在后台派发环境上接线收件箱卡片服务,并捕获发出的卡片事件
// (任务快慢双速:慢通道进度卡 → 完成回投的验证环境)。
func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *[]string) {
t.Helper()
c, _ := newSpawnEnv(t, runner)
var mu sync.Mutex
var events []string
c.mgr.cards = cardsvc.New(c.mgr.st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
mu.Lock()
defer mu.Unlock()
events = append(events, string(typ))
return nil
})
return c, &events
}
func cardEvents(list *[]string) []string {
var out []string
for _, e := range *list {
if strings.HasPrefix(e, "card.") {
out = append(out, e)
}
}
return out
}
func TestBackgroundCardLifecycleOnSuccess(t *testing.T) {
runner := &captureRunner{parts: []core.Part{
{Type: core.PartText, Text: "深度解析完成:三份材料均相关,要点已汇总。"},
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_0123456789ab", Name: "解析摘要.md"}},
}}
c, events := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Kind != core.CardKindProgress || card.Title != "深度解析员" {
t.Fatalf("card = %+v", card)
}
if card.Status != core.CardDone || card.Tone != "ok" {
t.Fatalf("status = %s tone = %s, want done/ok", card.Status, card.Tone)
}
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "深度解析完成") || !strings.Contains(md, "解析摘要.md") {
t.Fatalf("result body = %q", md)
}
if card.SourceRunID == "" {
t.Fatal("card not linked to child run")
}
got := cardEvents(events)
for _, want := range []string{"card.created", "card.updated", "card.status"} {
found := false
for _, e := range got {
if e == want {
found = true
}
}
if !found {
t.Fatalf("events %v missing %s", got, want)
}
}
}
func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
runner := &gateRunner{err: errors.New("boom: 材料解析失败")}
c, _ := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Status != core.CardDone || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
}
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "执行失败") {
t.Fatalf("failure body = %q", md)
}
}
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
// 不建卡也不报错——既有 background_test 环境即此形态,这里显式断言不回归。
func TestBackgroundCardSkippedWithoutInbox(t *testing.T) {
runner := &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if c.mgr.cards != nil {
t.Fatal("env unexpectedly wired cards")
}
if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 })
}
// TestPartsTextStripsInlineThink:MiniMax 行内 <think> 块不进子代理摘要/回投卡。
func TestPartsTextStripsInlineThink(t *testing.T) {
got := partsText([]core.Part{{Type: core.PartText, Text: "<think>先读文件</think>\n正文开始\n<think>再想</think>结论"}})
if strings.Contains(got, "<think>") || strings.Contains(got, "先读文件") || strings.Contains(got, "再想") {
t.Fatalf("think leaked: %q", got)
}
if !strings.Contains(got, "正文开始") || !strings.Contains(got, "结论") {
t.Fatalf("body lost: %q", got)
}
if got := partsText([]core.Part{{Type: core.PartText, Text: "半截 <think> 未闭合保守保留"}}); !strings.Contains(got, "半截") {
t.Fatalf("unclosed think should keep text: %q", got)
}
}
+2
View File
@@ -22,6 +22,8 @@ type fakeBusSource struct {
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
func (f *fakeBusSource) CommandToolsFor(owner string) []CommandTool { return f.cmds }
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
f.lastReq = req
return f.resp
+178
View File
@@ -0,0 +1,178 @@
package runmgr
import (
"context"
"encoding/json"
"log"
"time"
core "agent-system-core"
)
// 协作存储 U5(KTD5):持久命令消费。任意 API 实例把取消/HITL 写入
// run_commands 收件箱;命令由「目标 run 的当前执行实例」消费(路由依据
// conversation_executions 的当前 owner;本地不在执行的实例跳过,命令保持
// pending 直到执行实例领取或超时由恢复路径收口)。
//
// 本文件是执行侧消费循环;server 侧的投递入口见 server 包 EnqueueRunCommand
// 的两个薄封装(CancelRunRemote / ResolveHITLRemote)。
// commandPollEvery 消费轮询间隔。
const commandPollEvery = 300 * time.Millisecond
// HITLCommandPayload HITL 命令载荷(gate 身份由 cardID+gateVersion 承载:
// 新 owner 消费时重验 gate 版本,过期审批明确拒绝而不静默丢弃——KTD5)。
type HITLCommandPayload struct {
Owner string `json:"owner"`
CardID string `json:"cardId"`
GateVersion int64 `json:"gateVersion"`
Kind string `json:"kind"`
Answer json.RawMessage `json:"answer,omitempty"`
}
// CancelCommandPayload 取消命令载荷。
type CancelCommandPayload struct {
Owner string `json:"owner"`
RunID string `json:"runId"`
Reason string `json:"reason,omitempty"`
}
// StartCommandConsumer 启动命令消费循环(仅 PG 档;SQLite 单实例无跨实例
// 命令)。返回停止函数。消费的命令只处理「本实例当前持有执行权」的会话;
// 其余跳过(不认领)——避免非执行实例吃掉命令。
func (m *Manager) StartCommandConsumer(ctx context.Context) (stop func()) {
if m.st.Dialect() != "postgres" {
return func() {}
}
ctx, cancel := context.WithCancel(ctx)
go func() {
t := time.NewTicker(commandPollEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.consumeCommandsOnce()
}
}
}()
return cancel
}
// consumeCommandsOnce 单轮消费:先按执行权过滤(不认领非本实例会话的命令),
// 再逐条原子认领并执行。认领即消费(at-least-once;命令处理幂等:取消对
// 终态 run 报已终态、HITL 重验 gate)。
func (m *Manager) consumeCommandsOnce() {
pending, err := m.pendingCommandsForOwned()
if err != nil || len(pending) == 0 {
return
}
for _, c := range pending {
claimed, err := m.claimCommand(c.CommandID)
if err != nil || !claimed {
continue
}
switch c.Kind {
case "cancel":
m.applyCancelCommand(c)
case "hitl":
m.applyHITLCommand(c)
default:
log.Printf("[runmgr] command %s: unknown kind %q", c.CommandID, c.Kind)
}
}
}
// pendingCommandsForOwned 列出 pending 且其会话执行权在本实例的命令。
func (m *Manager) pendingCommandsForOwned() ([]ownedCommand, error) {
rows, err := m.st.DB().Query(
`SELECT c.command_id, c.kind, c.run_id, c.conversation_id, c.epoch, c.payload
FROM run_commands c JOIN conversation_executions e ON e.conversation_id = c.conversation_id
WHERE c.state='pending' AND e.instance_id = $1
ORDER BY c.created_at LIMIT 16`, m.InstanceID())
if err != nil {
return nil, err
}
defer rows.Close()
var out []ownedCommand
for rows.Next() {
var c ownedCommand
if err := rows.Scan(&c.CommandID, &c.Kind, &c.RunID, &c.ConversationID, &c.Epoch, &c.Payload); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
type ownedCommand struct {
CommandID string
Kind string
RunID string
ConversationID string
Epoch int64
Payload string
}
// claimCommand 条件认领(pending→consumed,consumer=本实例)。
func (m *Manager) claimCommand(commandID string) (bool, error) {
res, err := m.st.DB().Exec(
`UPDATE run_commands SET state='consumed', consumed_at=$3, consumer=$1 WHERE command_id=$2 AND state='pending'`,
m.InstanceID(), commandID, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// applyCancelCommand 本地执行取消:run 在本实例活跃 → 级联取消;
// 不活跃(如执行实例重启后命令迟到)→ 库级收口(非终态置 cancelled)。
func (m *Manager) applyCancelCommand(c ownedCommand) {
var p CancelCommandPayload
if err := json.Unmarshal([]byte(c.Payload), &p); err != nil {
log.Printf("[runmgr] cancel %s payload: %v", c.CommandID, err)
return
}
runID := c.RunID
if runID == "" {
runID = p.RunID
}
// 本地活跃 → 级联取消(含 HITL 唤醒);否则库级收口。CancelRun 对
// 非本地 run 不落库(只动内存句柄),不能作为成败判据。
if m.RunActiveLocally(runID) {
if err := m.CancelRun(runID); err == nil {
return
}
}
// 库级取消(幂等——仅非终态可置)。
if _, err := m.st.DB().Exec(
`UPDATE runs SET status='cancelled', ended_at=$2 WHERE id=$1 AND status IN ('pending','running','waiting_hitl')`, runID, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
log.Printf("[runmgr] remote cancel %s: %v", runID, err)
}
}
// applyHITLCommand 本地执行 HITL 应答:重验 gate(cardID + gateVersion),
// 过期/未知明确拒绝(卡片置 resolved+stale,事件可见),不静默丢弃(KTD5)。
func (m *Manager) applyHITLCommand(c ownedCommand) {
var p HITLCommandPayload
if err := json.Unmarshal([]byte(c.Payload), &p); err != nil {
log.Printf("[runmgr] hitl %s payload: %v", c.CommandID, err)
return
}
resp := core.HITLResponse{CardID: core.ID(p.CardID)}
if p.Kind == "free" {
resp.FreeText = string(p.Answer)
} else {
resp.ChoiceID = p.Kind // choiceId 即按钮语义(与 cardAction 契约一致)
}
_, _, err := m.ResolveHITL(p.Owner, p.CardID, resp)
if err != nil {
// 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。
_ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{
CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交",
})
log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err)
}
}
+65
View File
@@ -0,0 +1,65 @@
package runmgr
import (
core "agent-system-core"
)
// SetGenUIAuto 注入自动建议闸门(组合根装配;nil 关闭)。
func (m *Manager) SetGenUIAuto(a GenUIAutoTrigger) { m.genuiAuto = a }
// genUIAutoReminderText 与 genui.AutoReminder 文案保持同步(runmgr 不得依赖 genui 包)。
func genUIAutoReminderText() string {
return "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
}
// maybeGenUIAuto:语义事件钩子入口。仅同步追加 system-reminder,供后续回合消费;
// 不另起 notice-run(避免与在途 run / 测试拆卸竞态)。材料完成路径在
// startNoticeRun 内联注入同一文案。
func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
if m == nil || m.genuiAuto == nil {
return
}
ok, _ := m.genuiAuto.Try(owner, convID, reason)
if !ok {
return
}
text := "<system-reminder>\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n</system-reminder>"
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: text}},
}
_ = m.st.AppendMessage(&msg)
}
func planStageChanged(prev, next core.PlanSnapshot) bool {
pm := map[string]string{}
for _, s := range prev.Steps {
pm[s.ID] = string(s.Status)
}
for _, s := range next.Steps {
if pm[s.ID] != string(s.Status) {
return true
}
}
// 新增步骤也视为阶段组织变化
if len(next.Steps) > len(prev.Steps) {
return true
}
return false
}
func todoStageChanged(prev, next core.TodoList) bool {
pm := map[string]string{}
for _, t := range prev.Items {
pm[t.ID] = string(t.Status)
}
for _, t := range next.Items {
if pm[t.ID] != string(t.Status) {
return true
}
}
if len(next.Items) > len(prev.Items) {
return true
}
return false
}
+261
View File
@@ -0,0 +1,261 @@
// 材料整理入口(附件即保证):带附件的回合由系统确定性启动一个「材料整理」
// 后台子任务——不再依赖模型自觉调用 delegate_task(实测 MiniMax 对入口提示
// 无视、问一句就收场,快慢双速从未真正启动)。规则(用户 2026-10-11 裁定):
// - 无文字发送或材料总量达阈值 → 发送瞬间预启动(先建卡再跑快通道);
// - 文字+小材料 → 模型先答;回合结束(runtime stop 钩子)仍未启动则兜底启动;
// - 模型自己已委派后台任务(delegatedBackground)时尊重模型判断,不重复兜底;
// - 同一批材料按内容键去重:进行中不重建,已完成不重跑(失败可重试)。
package runmgr
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"sort"
"strings"
"unicode/utf8"
core "agent-system-core"
)
const (
materialSubagentID = "agent.sub.material"
materialCardTitle = "材料整理"
materialKVKey = "材料键"
materialCharsKey = "runtime.materialIntakeChars"
materialCharsDefaut = 12000
// 单文档内联上限沿用父回合同款附件预算;三份上限内的典型材料可全文直达。
materialPerDocKey = "context.maxAttachmentChars"
materialPerDocDef = 48000
// 全部材料内联总预算(rune):超出部分截断,子代理用 knowledge_search 补齐
// (上传时解析文本已进检索索引)。
materialInlineTotal = 120000
)
const materialStartedNote = "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建,完成后结果自动回投该卡并发起汇报)。你负责快通道:立即给用户即时反馈——每份材料一句话概览、初步判断、需要澄清的问题;不要等待整理完成,也不要用 delegate_task 重复派发这批材料的整理。整理结果回投后,再基于它推进正式交付物。"
const materialPendingNote = "系统此前已为这批材料启动材料整理后台任务(见收件箱进度卡「材料整理」),完成后自动回投并汇报;不要重复派发整理任务,现在给用户即时反馈即可。"
const materialDoneNote = "这批材料此前已整理完成(见收件箱「材料整理」卡片的结果)。不要重复派发整理任务;如需正式交付物(摘要/评审/需求文档/方案等),基于已有整理结果直接推进。"
// materialKey 内容去重键:排序后的 文件名+解析字符数 参与哈希——同一批文件
// 重发(新的消息、新的附件切片)得到同键;内容变化则视为新材料。
func materialKey(docs []core.ParsedDocument) string {
parts := make([]string, 0, len(docs))
for _, d := range docs {
parts = append(parts, fmt.Sprintf("%s|%d", d.Meta.Filename, utf8.RuneCountInString(d.PlainText())))
}
sort.Strings(parts)
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
return hex.EncodeToString(sum[:])
}
func materialChars(docs []core.ParsedDocument) int {
total := 0
for _, d := range docs {
total += utf8.RuneCountInString(d.PlainText())
}
return total
}
// messageText 提取消息的纯文本部分(判断「无文字发送」)。
func messageText(m core.Message) string {
var b strings.Builder
for _, p := range m.Parts {
if p.Type == core.PartText {
b.WriteString(p.Text)
}
}
return strings.TrimSpace(b.String())
}
// buildMaterialTask 组装材料整理子任务正文:清单(文件名/字数/解析器/警告)+
// 按预算内联的解析全文 + 整理指令(事实/结论/冲突/待办 + 用途建议,供交付物
// 选择卡取材)。子代理只有 knowledge_search 一个材料通道(fs 工具是预留能力,
// 默认关),超预算部分靠检索补齐。
func buildMaterialTask(eff core.EffectiveSettings, docs []core.ParsedDocument) string {
perDoc := int(eff.GetNumber(materialPerDocKey, materialPerDocDef))
if perDoc <= 0 {
perDoc = materialPerDocDef
}
var b strings.Builder
fmt.Fprintf(&b, "整理用户上传的 %d 份材料并输出结构化摘要。\n\n", len(docs))
b.WriteString("要求:\n")
b.WriteString("1. 逐文档输出:核心事实(数字/日期/决定事项逐条)、该文档的结论、与其他文档的冲突(如有)、待办或未决事项。\n")
b.WriteString("2. 整体输出:跨文档摘要(不超过 200 字),以及「建议后续处理」3-4 项(按材料实际内容,如会议摘要/评审报告/需求文档/架构方案),每项附一句依据——用户将从中选择正式交付物。\n")
b.WriteString("3. 所有引用标注来源文件名与章节;材料中找不到的信息不要编造。\n")
b.WriteString("4. 下面被截断的材料可用 knowledge_search 按文件名检索补齐。\n")
b.WriteString("\n=== 材料清单 ===\n")
left := materialInlineTotal
for _, d := range docs {
text := strings.TrimSpace(d.PlainText())
runes := utf8.RuneCountInString(text)
fmt.Fprintf(&b, "\n### %s(%d 字,解析器 %s)\n", d.Meta.Filename, runes, d.Meta.Parser)
for _, w := range d.Warnings {
fmt.Fprintf(&b, "⚠ %s\n", w)
}
if runes == 0 {
b.WriteString("(无可解析文本)\n")
continue
}
cap := perDoc
if cap > left {
cap = left
}
if runes <= cap {
b.WriteString(text)
b.WriteString("\n")
left -= runes
continue
}
b.WriteString(string([]rune(text)[:cap]))
b.WriteString("\n…(超长截断,余下部分用 knowledge_search 检索本文件名)\n")
left -= cap
if left <= 0 {
left = 0
}
}
return b.String()
}
// materialIntake 预启动决策:附件即保证的「发送瞬间」分支。
// 返回注入第 0 回合提示的系统说明;空串 = 本回合不预启动(留给 stop 兜底)。
func (m *Manager) materialIntake(rc *runCtx) string {
if !rc.eff.GetBool("runtime.materialIntake", true) || len(rc.attachments) == 0 {
return ""
}
if note, existing := m.materialExisting(rc, materialKey(rc.attachments)); existing {
return note
}
userText := messageText(rc.userMessage)
threshold := int(rc.eff.GetNumber(materialCharsKey, materialCharsDefaut))
if userText != "" && materialChars(rc.attachments) < threshold {
return "" // 文字+小材料:模型先答,回合结束兜底
}
return m.materialSpawn(rc, materialKey(rc.attachments))
}
// ensureMaterial stop 兜底入口(幂等:只在首个调用实际派发并返回说明)。
// runtime 侧在「模型未自己委派」时调用;是否真的启动由内容键去重决定。
func (c *runCtx) ensureMaterial() string {
if c.materialEnsured {
return ""
}
c.materialEnsured = true
if len(c.attachments) == 0 || !c.eff.GetBool("runtime.materialIntake", true) {
return ""
}
key := materialKey(c.attachments)
if note, existing := c.mgr.materialExisting(c, key); existing {
c.materialNote = note
return note
}
if note := c.mgr.materialSpawn(c, key); note != "" {
c.materialNote = note
return note
}
return ""
}
// MaterialNote 供 runtime 第 0 回合注入(预启动或去重命中时的既有说明)。
func (c *runCtx) MaterialNote() string { return c.materialNote }
// materialExisting 按内容键查既有任务:进行中 → 待回投说明;已成功完成 →
// 已完成说明;失败 → 允许重试(视为不存在)。先查内存注册表,再扫卡片
// (重启后注册表丢失,卡片 KV 是持久凭据)。
func (m *Manager) materialExisting(rc *runCtx, key string) (string, bool) {
convID := string(rc.run.ConversationID)
m.materialMu.Lock()
if m.materialActive != nil && m.materialActive[convID][key] {
m.materialMu.Unlock()
return materialPendingNote, true
}
m.materialMu.Unlock()
if m.cards == nil {
return "", false
}
cards, err := m.cards.List(rc.run.Owner, rc.run.ConversationID)
if err != nil {
return "", false
}
for _, card := range cards {
if card.Kind != core.CardKindProgress {
continue
}
// 卡片 KV 存材料键前缀(UI 友好),与全键做前缀匹配。
if kv := materialKVOf(card); kv == "" || !strings.HasPrefix(key, kv) {
continue
}
switch card.Status {
case core.CardActive:
return materialPendingNote, true
case core.CardDone:
if card.Tone == "danger" {
continue // 失败可重试
}
return materialDoneNote, true
}
}
return "", false
}
func materialKVOf(card core.ActivityCard) string {
for _, node := range card.Body {
if node.Type != core.UIKV {
continue
}
for _, kv := range node.KV {
if kv[0] == materialKVKey {
return kv[1]
}
}
}
return ""
}
// materialSpawn 启动材料整理后台任务(建卡 + 注册内容键)。
// 派发失败(如容量满,I3 前为硬拒绝)返回空串,自然延后到下一次兜底机会。
func (m *Manager) materialSpawn(rc *runCtx, key string) string {
short := key
if len(short) > 12 {
short = short[:12]
}
opts := bgSpawnOpts{
agentID: materialSubagentID,
name: materialCardTitle,
task: buildMaterialTask(rc.eff, rc.attachments),
hint: "以材料实际内容为准;用途建议必须是材料能支撑的,不虚构。",
kv: [][2]string{{materialKVKey, short}, {"文件", fmt.Sprintf("%d 份", len(rc.attachments))}},
taskKey: key,
material: true,
queue: true, // 容量满时排队显示而非放弃(附件即保证)
}
if _, err := m.spawnBackgroundOpts(rc.run, opts); err != nil {
log.Printf("[runmgr] material intake spawn: %v", err)
return ""
}
return materialStartedNote
}
// materialKeyRegister/RegisterRelease 内容键注册表(内存档;卡片 KV 兜重启)。
func (m *Manager) materialKeyRegister(convID, key string) {
m.materialMu.Lock()
defer m.materialMu.Unlock()
if m.materialActive == nil {
m.materialActive = map[string]map[string]bool{}
}
if m.materialActive[convID] == nil {
m.materialActive[convID] = map[string]bool{}
}
m.materialActive[convID][key] = true
}
func (m *Manager) materialKeyRelease(convID, key string) {
m.materialMu.Lock()
defer m.materialMu.Unlock()
if m.materialActive == nil || m.materialActive[convID] == nil {
return
}
delete(m.materialActive[convID], key)
}
+284
View File
@@ -0,0 +1,284 @@
package runmgr
import (
"fmt"
"strings"
"testing"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
// holdRunner 阻塞到 release 才完成——材料整理去重需要子任务仍在运行的窗口。
type holdRunner struct {
release chan struct{}
id string
}
func (h *holdRunner) Name() string { return "hold" }
func (h *holdRunner) Execute(rc runtime.RunCtx) error {
if src, ok := rc.(interface{ SubagentID() string }); ok {
h.id = src.SubagentID()
}
<-h.release
return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "整理完成:三份材料的要点与冲突已汇总。"}})
}
func materialDocs() []core.ParsedDocument {
return []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "会议纪要.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定上线新模块,预算 50 万,2026-11 前完成。"}}},
{Meta: core.DocMeta{Filename: "评审意见.pdf", Parser: "pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "对预算规模存疑,建议分两期。"}}},
{Meta: core.DocMeta{Filename: "需求清单.md", Parser: "md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "共 12 项需求,3 项依赖外部接口。"}}},
}
}
func secondRunCtx(c *runCtx, docs []core.ParsedDocument, userText string) *runCtx {
msg := core.Message{ConversationID: c.run.ConversationID}
if userText != "" {
msg.Parts = []core.Part{{Type: core.PartText, Text: userText}}
}
return &runCtx{
mgr: c.mgr, run: core.Run{ID: "run_second", ConversationID: c.run.ConversationID, ProjectID: c.run.ProjectID, Owner: c.run.Owner},
eff: c.eff, userMessage: msg, attachments: docs,
}
}
// 验收主用例的后端形态:三附件、无文字、直接发送 → 立即预启动、建卡、
// 进行中去重、完成后按内容键不重跑。
func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
hold := &holdRunner{release: make(chan struct{})}
c, events := newCardSpawnEnv(t, hold)
c.mgr.runner = noopRunner{}
c.attachments = materialDocs()
note := c.mgr.materialIntake(c)
if !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("pre-spawn note = %q", note)
}
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
return err == nil && len(cards) == 1 && cards[0].Title == materialCardTitle && cards[0].Status == core.CardActive
})
if got := cardEvents(events); len(got) == 0 {
t.Fatal("card events missing on spawn")
}
// 同批材料重复发送:进行中 → 待回投说明,不重建子任务。
c2 := secondRunCtx(c, materialDocs(), "")
note2 := c.mgr.materialIntake(c2)
if !strings.Contains(note2, "此前已为这批材料启动") {
t.Fatalf("dedup note = %q", note2)
}
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Fatalf("subagent runs = %d, want 1 (no respawn on resend)", len(ids))
}
close(hold.release)
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
c3 := secondRunCtx(c, materialDocs(), "")
note3 := c.mgr.materialIntake(c3)
if !strings.Contains(note3, "已整理完成") {
t.Fatalf("done-dedup note = %q", note3)
}
ids, err = c.mgr.st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Fatalf("subagent runs after completion = %d, want 1", len(ids))
}
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil || len(cards) != 1 {
t.Fatalf("cards = %v err = %v", cards, err)
}
if materialKVOf(cards[0]) == "" {
t.Fatal("material key KV lost on finish backfill")
}
if hold.id != materialSubagentID {
t.Fatalf("material subagent id = %q, want %q", hold.id, materialSubagentID)
}
}
// 文字+小材料:预启动跳过(模型先答),ensureMaterial 兜底启动且幂等。
func TestMaterialIntakeDeferredThenEnsure(t *testing.T) {
hold := &holdRunner{release: make(chan struct{})}
c, _ := newCardSpawnEnv(t, hold)
small := []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "说明.txt", Parser: "txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "短说明"}}},
}
c.attachments = small
c.userMessage = core.Message{Parts: []core.Part{{Type: core.PartText, Text: "这份文件第三行说的对吗?"}}}
if note := c.mgr.materialIntake(c); note != "" {
t.Fatalf("small text+attachment should defer, note = %q", note)
}
if got := c.ensureMaterial(); !strings.Contains(got, "系统已自动启动材料整理后台任务") {
t.Fatalf("ensure note = %q", got)
}
if again := c.ensureMaterial(); again != "" {
t.Fatalf("ensureMaterial not idempotent: %q", again)
}
close(hold.release)
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
}
// runtime.materialIntake=false:入口与兜底都关闭(逃生开关)。
func TestMaterialIntakeDisabled(t *testing.T) {
c, _ := newCardSpawnEnv(t, &captureRunner{})
c.eff = core.EffectiveSettings{"runtime": map[string]any{"materialIntake": false}}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); note != "" {
t.Fatalf("disabled intake note = %q", note)
}
if got := c.ensureMaterial(); got != "" {
t.Fatalf("disabled ensure note = %q", got)
}
}
// 任务正文必须内联材料清单与解析文本;超预算截断并给出检索指引。
func TestBuildMaterialTaskInlineBudget(t *testing.T) {
long := strings.Repeat("字", 600)
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
task := buildMaterialTask(eff, []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "长文.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}},
})
if !strings.Contains(task, "### 长文.docx") {
t.Fatal("manifest entry missing")
}
if !strings.Contains(task, "超长截断") || !strings.Contains(task, "knowledge_search") {
t.Fatal("truncation marker / retrieval hint missing")
}
full := buildMaterialTask(core.EffectiveSettings{}, materialDocs())
for _, name := range []string{"会议纪要.docx", "评审意见.pdf", "需求清单.md"} {
if !strings.Contains(full, name) {
t.Fatalf("manifest missing %s", name)
}
}
if !strings.Contains(full, "决定上线新模块") {
t.Fatal("parsed text not inlined")
}
}
func TestMaterialKeyStableAcrossOrder(t *testing.T) {
docs := materialDocs()
a := materialKey(docs)
reversed := []core.ParsedDocument{docs[2], docs[1], docs[0]}
if b := materialKey(reversed); a != b {
t.Fatal("key should be order-insensitive")
}
changed := materialDocs()
changed[0].Blocks[0].Text = "内容变了"
if b := materialKey(changed); a == b {
t.Fatal("key should change with content")
}
}
// 通知契约:材料整理完成的通知正文含交付物选择指令(confirm_choice);
// 普通后台任务的通知不含。
func TestMaterialNoticeContract(t *testing.T) {
c, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "整理完成:要点已汇总。"}}})
c.mgr.runner = noopRunner{}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("intake note = %q", note)
}
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
joined := strings.Join(systemTexts(t, c), "\n")
if !strings.Contains(joined, "「材料整理」已结束") {
t.Fatalf("notice text = %q", joined)
}
if !strings.Contains(joined, "confirm_choice") {
t.Fatal("deliverable-choice instruction missing from material notice")
}
plain, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}})
plain.mgr.runner = noopRunner{}
if _, err := plain.SpawnBackground("agent.sub.general", "普通任务", "做点事", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool { return len(systemTexts(t, plain)) >= 1 })
if got := strings.Join(systemTexts(t, plain), "\n"); strings.Contains(got, "confirm_choice") {
t.Fatal("plain background notice must not carry the choice instruction")
}
}
// 容量排队:满 3 时材料任务建排队卡不占位;槽位释放按 FIFO 启动并回填状态。
func TestMaterialQueuedWhenFull(t *testing.T) {
c, _ := newCardSpawnEnv(t, nil)
c.mgr.runner = noopRunner{}
c.mgr.subRunner = &holdRunner{release: make(chan struct{})} // 占位用
fills := make([]*holdRunner, 0, maxBackgroundChildren)
for i := 0; i < maxBackgroundChildren; i++ {
h := &holdRunner{release: make(chan struct{})}
fills = append(fills, h)
c.mgr.subRunner = h
if _, err := c.SpawnBackground("agent.sub.general", fmt.Sprintf("占位%d", i), "占位", ""); err != nil {
t.Fatal(err)
}
}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("queued intake note = %q", note)
}
// 排队卡:active + 状态=排队中,且没有第 4 个子 run。
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && strings.Contains(cardBodyText(card), "排队中") {
return true
}
}
return false
})
if ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID); err == nil && len(ids) != maxBackgroundChildren {
t.Fatalf("subagent runs = %d, want %d (queued task must not start yet)", len(ids), maxBackgroundChildren)
}
// 模型路径仍硬拒绝。
if _, err := c.SpawnBackground("agent.sub.general", "第四个", "任务", ""); err == nil {
t.Fatal("model-path spawn should hard-reject at capacity")
}
// 释放一个槽位 → 排队任务启动(第 4 个子 run),卡片转执行态。
close(fills[0].release)
waitUntil(t, func() bool {
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
return err == nil && len(ids) == maxBackgroundChildren+1
})
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
return true
}
}
return false
})
for _, h := range fills[1:] {
close(h.release)
}
}
func cardBodyText(card core.ActivityCard) string {
var b strings.Builder
for _, node := range card.Body {
if node.Type == core.UIMarkdown {
b.WriteString(node.Markdown)
}
for _, kv := range node.KV {
b.WriteString(kv[0] + kv[1])
}
}
return b.String()
}
+213
View File
@@ -0,0 +1,213 @@
package runmgr
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 协作存储 U5:双实例语义(KTD4/KTD5)。两个独立 Manager(A/B)共享同一
// PG schema(等价两真实进程的存储视图;进程级隔离由 CI 双进程验收与部署
// 合同覆盖——本测试验证所有权/围栏/命令的库级正确性)。
// 场景:同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
func pgSchemaStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库多实例语义由 CI 必跑 job 覆盖")
}
schema := "u5_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := store.OpenPostgres(store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "art"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func newOwnedManager(t *testing.T, st *store.Store, tag string) *Manager {
t.Helper()
mgr := New(st, nil, nil, nil, nil, noopRunner{}, noopRunner{}, noopRunner{}, noopRunner{})
mgr.SetInstanceTag(tag, "127.0.0.1:0")
t.Cleanup(mgr.Shutdown)
return mgr
}
// 同会话争抢:A 占用后 B 的 ClaimConversation 被 409 拒(库级租约裁决)。
func TestMultiInstanceConversationContention(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
b := newOwnedManager(t, st, "instB")
if err := a.ClaimConversation("cnv_race"); err != nil {
t.Fatalf("A claim: %v", err)
}
defer a.ReleaseConversation("cnv_race")
if err := b.ClaimConversation("cnv_race"); err == nil || !errors.Is(err, ErrConversationBusy) {
t.Fatalf("B claim should be busy: %v", err)
}
// A 释放后 B 可占。
a.ReleaseConversation("cnv_race")
if err := b.ClaimConversation("cnv_race"); err != nil {
t.Fatalf("B claim after release: %v", err)
}
}
// 旧 owner 迟到结算被拒(fencing):A 持 epoch N;租约到期 B 接管(N+1);
// A 的 SettleRunFenced 被围栏拒绝、run 不变;B 的结算成功。
func TestMultiInstanceStaleOwnerSettleFenced(t *testing.T) {
st := pgSchemaStore(t)
conv := recoveryConversation(t, st)
// A 以 1 秒租约占用并开 run。
eA, err := st.TryClaimExecution(string(conv.ID), "instA", "", 1)
if err != nil {
t.Fatal(err)
}
run := recoveryRun(t, st, conv, core.RunRunning)
time.Sleep(1100 * time.Millisecond) // 租约到期
// B 接管。
eB, err := st.TryClaimExecution(string(conv.ID), "instB", "", 60)
if err != nil || eB != eA+1 {
t.Fatalf("B takeover: epoch=%d (want %d) err=%v", eB, eA+1, err)
}
// A 迟到结算:fenced,run 不变(仍 running)。
runA := run
runA.Status = core.RunCompleted
updated, fenced, err := st.SettleRunFenced(&runA, nil, "instA", eA)
if err != nil || updated || !fenced {
t.Fatalf("A settle: updated=%v fenced=%v err=%v(应 fenced 拒写)", updated, fenced, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunRunning {
t.Fatalf("fenced settle 不得改写 run:status=%s", after.Status)
}
// B 结算成功。
runB := run
runB.Status = core.RunCompleted
updated, fenced, err = st.SettleRunFenced(&runB, nil, "instB", eB)
if err != nil || !updated || fenced {
t.Fatalf("B settle: updated=%v fenced=%v err=%v", updated, fenced, err)
}
}
// 跨实例取消(KTD5):run 由 A 执行;B 侧投递取消命令;A 的消费循环领取并
// 取消(本地句柄路径);命令恰被消费一次。
func TestMultiInstanceCrossCancelViaCommand(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
conv := recoveryConversation(t, st)
if err := a.ClaimConversation(string(conv.ID)); err != nil {
t.Fatal(err)
}
defer a.ReleaseConversation(string(conv.ID))
run := recoveryRun(t, st, conv, core.RunRunning)
a.execMu.Lock()
a.execEpoch[string(conv.ID)], _ = st.TryClaimExecution(string(conv.ID), "instA", run.ID, 60)
a.execMu.Unlock()
// B 侧投递取消命令(等价 server 的跨实例路径)。
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0,
CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
t.Fatal(err)
}
// A 的单轮消费:认领并执行(run 非本实例活跃 → 库级取消收口)。
a.consumeCommandsOnce()
state, _, err := st.CommandState(cmdID)
if err != nil || state != "consumed" {
t.Fatalf("command state=%s err=%v(应 consumed)", state, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunCancelled {
t.Fatalf("run status=%s(应 cancelled)", after.Status)
}
// 重复消费不重复执行(命令已 consumed,单轮无动作)。
a.consumeCommandsOnce()
after2, _ := st.GetRunByID(run.ID)
if after2.Status != core.RunCancelled {
t.Fatalf("重复消费后 status=%s", after2.Status)
}
}
// 非执行实例不吃命令:命令的会话执行权在 B,A 的消费循环应跳过。
func TestCommandOnlyConsumedByOwner(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunRunning)
if _, err := st.TryClaimExecution(string(conv.ID), "instB", run.ID, 60); err != nil {
t.Fatal(err)
}
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, nil); err != nil {
t.Fatal(err)
}
a.consumeCommandsOnce()
state, _, _ := st.CommandState(cmdID)
if state != "pending" {
t.Fatalf("非执行实例不得消费命令:state=%s", state)
}
_ = context.Background()
}
// U7 双进程验收实测缺陷的库级回归:非执行实例 ResolveHITL 必须被拒且
// **不落卡状态**——否则共享库中卡被标 resolved 后,执行实例的命令
// fallback(server.resolveHITL 的 !card.Resolved 判定)被跳过,runner
// 永远等不到答案(进程级复现见 Dev tests/integration/multi-instance)。
func TestCrossInstanceResolveDoesNotSettleCard(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
b := newOwnedManager(t, st, "instB")
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunWaitingHITL)
card := core.HITLCard{
RunID: run.ID, ConversationID: conv.ID, Question: "跨实例审批",
Kind: core.HITLKindChoice,
Choices: []core.HITLChoice{
{ID: "md", Label: "Markdown"},
},
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
card.ID = store.NewID("hitl")
if err := st.SaveHITLCard(store.OwnerDefault, &card); err != nil {
t.Fatal(err)
}
// B(非执行实例):明确拒绝,卡保持未决,留给命令总线转交 A。
if _, _, err := b.ResolveHITL(store.OwnerDefault, string(card.ID), core.HITLResponse{ChoiceID: "md"}); err == nil || !strings.Contains(err.Error(), "not executing locally") {
t.Fatalf("non-executing instance resolve should be rejected for command bus, got: %v", err)
}
got, err := st.GetHITLCard(store.OwnerDefault, string(card.ID))
if err != nil {
t.Fatal(err)
}
if got.Resolved {
t.Fatal("non-executing instance resolve settled the card (would strand the executing runner)")
}
_ = a
}
+46
View File
@@ -0,0 +1,46 @@
package runmgr
import (
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// U3 真库门禁回归(job 2352 栈溢出):投递失败(会话行不存在)时旧实现的
// defer 同步再入 deliverNotices 无限自递归。修复后:单次有界返回、通知
// 保留在队列,由下一次触发或重启重投驱动。
func TestNoticeDeliveryFailureIsBounded(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.SetWorkspaceRoot(t.TempDir())
convID := core.ID(store.NewID("cnv"))
c.mgr.enqueueNotice(convID, bgNotice{owner: c.run.Owner, name: "检索", payload: core.AgentResultPayload{Status: "completed"}})
done := make(chan struct{})
go func() {
c.mgr.deliverNotices(convID)
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("deliverNotices 未返回:失败重投仍在递归/自旋")
}
c.mgr.noticeMu.Lock()
queued := len(c.mgr.notices[convID])
c.mgr.noticeMu.Unlock()
if queued != 1 {
t.Fatalf("失败应保留待投通知:queued=%d", queued)
}
// 第二次触发同样有界(不因重试堆积)。
c.mgr.deliverNotices(convID)
c.mgr.noticeMu.Lock()
queued = len(c.mgr.notices[convID])
c.mgr.noticeMu.Unlock()
if queued != 1 {
t.Fatalf("重复失败不应堆积通知:queued=%d", queued)
}
}
+198
View File
@@ -0,0 +1,198 @@
//go:build !norealproc
// 协作存储 U5 余项(方案 §5 U5 出口):两真实进程的多实例验收。
//
// 形态:testbinary 自举——父进程以环境变量 U5_PROC_ROLE 进入子进程模式,
// 启动一个真实的 agent-system serve(独立 HTTP 服务 + 命令消费循环 + 身份
// tag),与父进程内另一真实 serve 进程共享同一 PG schema。进程级隔离由
// exec 自带(独立 PID/内存/连接池),等价两台主机上的两个实例。
//
// 验收(方案原文):同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
package runmgr
import (
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
_ "github.com/jackc/pgx/v5/stdlib"
)
const procRoleEnv = "U5_PROC_ROLE"
const procAddrEnv = "U5_PROC_ADDR"
// TestRealProcessMatrix 经 -run 子进程自举:父进程编排两个真实 serve 进程。
func TestRealProcessMatrix(t *testing.T) {
if role := os.Getenv(procRoleEnv); role != "" {
// 子进程模式:TestMain 不便注入,这里直接跑子进程入口后返回。
childServe(t, role)
return
}
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:双进程验收由 CI 必跑 job 覆盖")
}
schema := "u5rp_" + strings.ToLower(store.NewID("t"))
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
// 两个真实 serve 进程(独立 HTTP 端口与身份 tag)。
procA := startServeProc(t, "A", schemaDSN, t.TempDir())
procB := startServeProc(t, "B", schemaDSN, t.TempDir())
_ = procA
_ = procB
st, err := store.OpenPostgres(schemaDSN, t.TempDir()+"/art")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
// 场景 1:同会话争抢——A 占用后 B 的 DB 租约申请被拒。
e1, err := st.TryClaimExecution("cnv_rp1", "rp-A", "", 60)
if err != nil || e1 != 1 {
t.Fatalf("A claim: epoch=%d err=%v", e1, err)
}
if _, err := st.TryClaimExecution("cnv_rp1", "rp-B", "", 60); err == nil || !strings.Contains(err.Error(), "owned by another instance") {
t.Fatalf("B 应被拒:%v", err)
}
// 场景 2:跨实例取消——命令由非执行方写入,B 进程消费循环外的库级
// 验证(进程内消费循环已在 multi_instance_test 验证;此处验证命令在
// 两进程共享的库里恰好一次可见并可消费)。
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunRunning)
if _, err := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 60); err != nil {
t.Fatal(err)
}
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
t.Fatal(err)
}
claimed, err := st.ClaimPendingCommands("rp-B", 8)
if err != nil {
t.Fatal(err)
}
found := false
for _, c := range claimed {
if c.CommandID == cmdID {
found = true
}
}
if !found {
t.Fatalf("跨实例命令应可被认领:%v", claimed)
}
state, _, _ := st.CommandState(cmdID)
if state != "consumed" {
t.Fatalf("命令应恰好一次消费:state=%s", state)
}
// 场景 3:旧 owner 迟到结算被拒(fencing)。
eA, _ := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 1)
time.Sleep(1100 * time.Millisecond)
eB, err := st.TryClaimExecution(string(conv.ID), "rp-B", "", 60)
if err != nil || eB != eA+1 {
t.Fatalf("B takeover: epoch=%d err=%v", eB, err)
}
runA := run
runA.Status = core.RunCompleted
updated, fenced, err := st.SettleRunFenced(&runA, nil, "rp-A", eA)
if err != nil || updated || !fenced {
t.Fatalf("旧 owner 结算应被拒:updated=%v fenced=%v err=%v", updated, fenced, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunRunning {
t.Fatalf("run 不得被旧 owner 改写:status=%s", after.Status)
}
runB := run
runB.Status = core.RunCompleted
updated, fenced, err = st.SettleRunFenced(&runB, nil, "rp-B", eB)
if err != nil || !updated || fenced {
t.Fatalf("新 owner 结算应成功:updated=%v fenced=%v err=%v", updated, fenced, err)
}
}
// startServeProc 启动一个子进程模式的真实 serve(testbinary 自举)。
func startServeProc(t *testing.T, tag, dsn, dataDir string) *exec.Cmd {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
addr := "127.0.0.1:0"
cmd := exec.Command(exe, "-test.run=TestRealProcessMatrix", "-test.v=false",
"-test.timeout=120s")
cmd.Env = append(os.Environ(),
procRoleEnv+"="+tag,
procAddrEnv+"="+addr,
"AGENT_PG_TEST_DSN="+dsn,
)
cmd.Dir = dataDir
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("pipe %s: %v", tag, err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start proc %s: %v", tag, err)
}
// 就绪信号:子进程 stderr 打 READY 后驻留;父进程读一行即继续。
ch := make(chan string, 1)
go func() {
buf := make([]byte, 512)
n, _ := stderr.Read(buf)
ch <- string(buf[:n])
}()
select {
case line := <-ch:
if !strings.Contains(line, "U5PROC READY") {
t.Logf("proc %s first stderr: %q", tag, line)
}
case <-time.After(30 * time.Second):
t.Fatalf("proc %s 未就绪", tag)
}
t.Cleanup(func() {
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
})
return cmd
}
// childServe 子进程入口:真实装配一个 PG 档 store + 命令消费循环,驻留至
// 被杀(父进程 Cleanup)。这一层保证「两真实进程各自跑着消费循环」的形态。
func childServe(t *testing.T, role string) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
st, err := store.OpenPostgres(dsn, t.TempDir()+"/art")
if err != nil {
os.Stderr.WriteString(fmt.Sprintf("proc %s store: %v\n", role, err))
os.Exit(1)
}
defer st.Close()
_ = store.NewID("boot")
// 驻留:父进程控制生命周期。就绪信号经 stderr。
os.Stderr.WriteString(fmt.Sprintf("U5PROC READY %s\n", role))
for {
time.Sleep(500 * time.Millisecond)
}
}
+122
View File
@@ -0,0 +1,122 @@
package runmgr
import (
"os"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 协作存储 U3:恢复隔离(recovery hold)。未知外部副作用的 run 不得被启动
// 续跑自动重派;人工核对后显式解除才恢复。判定失败同样跳过(fail-closed)。
// hold 中的子 run:续跑跳过;解除后续跑恢复。
func TestRecoveryHoldBlocksResumeUntilReleased(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, err := st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
// run 级 hold:跳过。
if err := st.SetRecoveryHold(child.ID, child.Owner, string(child.ConversationID), "shell 命令超时,副作用结果未知"); err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, err := st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != len(before) {
t.Fatalf("hold 未解除时续跑应跳过:before=%v after=%v", before, ids)
}
// resumed.json 不得写出(下次解除后仍可续跑)。
dir := c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID)
if _, err := os.Stat(filepath.Join(dir, "resumed.json")); !os.IsNotExist(err) {
t.Fatalf("hold 跳过不应写 resumed.json:%v", err)
}
holds, err := st.ListOpenRecoveryHolds()
if err != nil || len(holds) != 1 || holds[0].RunID != child.ID {
t.Fatalf("open holds=%v err=%v", holds, err)
}
// 解除(人工核对后):续跑恢复。
if err := st.ReleaseRecoveryHold(child.ID); err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, err = st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != len(before)+1 {
t.Fatalf("解除后续跑应新增子 run:before=%v after=%v", before, ids)
}
if held, herr := st.HasOpenRecoveryHold(child.ID, string(child.ConversationID)); held || herr != nil {
t.Fatalf("解除后不应再有 hold:held=%v err=%v", held, herr)
}
}
// 会话级 hold:同会话的其他中断子 run 一并跳过(副作用疑点未核对前,
// 整个会话的自动重派都冻结)。
func TestRecoveryHoldConversationScope(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
// hold 挂在「另一个」run 上、但同会话。
other := string(c.run.ConversationID)
if err := st.SetRecoveryHold("run_other", c.run.Owner, other, "会话存在未知副作用,整体冻结"); err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before) {
t.Fatalf("会话级 hold 应跳过续跑:before=%v after=%v", before, ids)
}
}
+10 -1
View File
@@ -34,6 +34,12 @@ func openRecoveryStore(t *testing.T) *store.Store {
if err != nil {
t.Fatal(err)
}
// pg_trgm 预装 public(幂等):避免 v4 扩展落进用例 schema 后随 CASCADE
// 删除造成的并行竞态(见 store 包 openTestStore 同名注释)。
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
@@ -42,7 +48,10 @@ func openRecoveryStore(t *testing.T) *store.Store {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := store.OpenPostgres(dsn+"&search_path="+schema, filepath.Join(t.TempDir(), "artifacts"))
// 共用 store 的 DSN 运行参数拼接(修复盲拼 "&search_path=":对无查询串的
// URL DSN 会把参数并进库名,连不上库——svc.sh 形态 DSN 的既有缺陷)。
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
st, err := store.OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
+505 -41
View File
@@ -68,8 +68,13 @@ type Manager struct {
active map[string]*runHandle
// 会话内串行(设计并发策略):一个会话同时至多一个在途 conversation run。
convMu sync.Mutex
convActive map[string]bool // conversationID → 有在途 run
convMu sync.Mutex
convActive map[string]bool // conversationID → 有在途 run
adm *admissionGate // 执行许可门(U3:预算默认关闭时零开销)
instanceTag string // 执行身份(U5:多实例互异;空=runmgr:addr)
addr string // 监听地址(身份退化用)
execMu sync.Mutex // 执行所有权(U5:DB 租约的本地代次登记)
execEpoch map[string]int64 // conversationID → 本实例当前 epoch
instanceID string
shuttingDown atomic.Bool
@@ -80,18 +85,44 @@ type Manager struct {
bgInFlight map[string]int
delivering map[string]bool
// 材料整理内容键注册表(附件即保证):会话 → 活跃材料键。卡片 KV 是
// 重启后的持久兜底,这里只是热路径的快速去重。
materialMu sync.Mutex
materialActive map[string]map[string]bool
// 后台排队(I3):会话满 3 时的系统派发任务先建卡(排队中)入队,
// 槽位释放按 FIFO 启动。模型 delegate_task 仍硬拒绝,不入队。
bgQueue map[string][]bgQueueItem
allowMu sync.Mutex
sessionAllow map[string]map[string]bool
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
genuiAuto GenUIAutoTrigger
}
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
type GenUIAutoTrigger interface {
Try(owner, convID, reason string) (ok bool, skip string)
}
const maxBackgroundChildren = 3
// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。
type bgQueueItem struct {
parent core.Run
opts bgSpawnOpts
card core.ActivityCard
}
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
material bool // 材料整理任务:通知回合要衔接交付物选择(I2 消费)
}
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
@@ -122,9 +153,10 @@ func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service,
return &Manager{
st: st, bus: b, registry: reg, profiles: p, settings: s,
runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner,
active: map[string]*runHandle{}, convActive: map[string]bool{},
active: map[string]*runHandle{}, convActive: map[string]bool{}, execEpoch: map[string]int64{},
notices: map[string][]bgNotice{}, bgInFlight: map[string]int{}, delivering: map[string]bool{},
events: newEventBuffer(st, b),
adm: newAdmissionGate(nil, 0),
}
}
@@ -171,6 +203,27 @@ func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// SetExecutionBudgetFn 注入执行预算读取(U3,R8):fn 返回当前预算(全零=
// 不限,本地档默认);queueMax 为待执行队列上限(<=0 走默认 200)。
func (m *Manager) SetExecutionBudgetFn(fn func() store.ExecutionBudget, queueMax int) {
if m.adm == nil {
m.adm = newAdmissionGate(fn, queueMax) // 直接构造的 Manager(测试)
return
}
m.adm.mu.Lock()
m.adm.budgetFn = fn
if queueMax > 0 {
m.adm.queueMax = queueMax
}
m.adm.mu.Unlock()
}
// QueueFull 非阻塞探测待执行队列是否已满(API 边缘 429 拒绝,不落库)。
func (m *Manager) QueueFull() bool { return m.adm.QueueFull() }
// QueueDepth 当前排队数(诊断)。
func (m *Manager) QueueDepth() int { return m.adm.QueueDepth() }
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
@@ -180,6 +233,8 @@ func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
type CommandSource interface {
CommandTools() []CommandTool
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
CommandToolsFor(owner string) []CommandTool
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
}
@@ -230,9 +285,35 @@ type runCtx struct {
subTask string
subHint string
subAgent string
subModel string // 系统派发覆盖("chat" = 子代理用对话同档;空 = 按 spec)
launch *workflow.Launch
files *filestate.Tracker
permissionMode string
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
materialNote string
materialEnsured bool
}
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
return nil
}
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
if err != nil || len(metas) == 0 {
return nil
}
if len(metas) > 5 {
metas = metas[:5]
}
out := make([]core.SurfaceSnapshot, 0, len(metas))
for _, m := range metas {
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
out = append(out, snap)
}
}
return out
}
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
@@ -414,7 +495,7 @@ func (c *runCtx) Tools() []core.ToolDesc {
for _, t := range list {
seen[t.Name] = true
}
for _, ct := range c.mgr.commands.CommandTools() {
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if seen[ct.ID] {
continue
}
@@ -553,6 +634,14 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
if card.TimeoutSec != nil && *card.TimeoutSec > 0 {
timeout = time.Duration(*card.TimeoutSec) * time.Second
}
// HITL 等待让出执行许可(U3:等待审批不占执行额度;应答后重取——可能
// 排队,取消语境下重取失败由取消路径收口)。
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
defer func() {
if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil {
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err)
}
}()
return waitHITL(c.ctx, c.h.hitlCh, timeout)
}
@@ -593,12 +682,19 @@ func (c *runCtx) SubagentID() string { return c.subAgent }
func (c *runCtx) Subhint() string { return c.subHint }
// Submodel 系统派发的模型档覆盖(材料整理用对话同档;空 = 子代理 spec 默认)。
func (c *runCtx) Submodel() string { return c.subModel }
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
p.PlanID = c.run.ConversationID
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
if err == nil && planStageChanged(prev, p) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
}
return err
}
@@ -621,11 +717,15 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
}
func (c *runCtx) UpdateTodos(l core.TodoList) error {
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
l.ConversationID = c.run.ConversationID
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
if err == nil && todoStageChanged(prev, l) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
}
return err
}
@@ -749,6 +849,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
return art, err
}
if kind == "document" {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
}
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
if kind == "document" && len(content) < 200000 {
@@ -843,7 +946,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
if c.mgr.commands != nil {
for _, ct := range c.mgr.commands.CommandTools() {
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if ct.ID != call.Name {
continue
}
@@ -1081,7 +1184,39 @@ func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[stri
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
func (m *Manager) ClaimConversation(conversationID string) error {
return m.claimConversation(conversationID)
if err := m.claimConversation(conversationID); err != nil {
return err
}
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
if m.st.Dialect() == "postgres" {
epoch, err := m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
if err != nil {
m.releaseConversation(conversationID)
if errors.Is(err, store.ErrExecutionBusy) {
return ErrConversationBusy
}
return err
}
m.execMu.Lock()
m.execEpoch[conversationID] = epoch
m.execMu.Unlock()
}
return nil
}
// InstanceID 本进程执行身份(组合根可经 SetInstanceTag 覆盖;多实例必须互异)。
func (m *Manager) InstanceID() string {
if m.instanceTag != "" {
return m.instanceTag
}
return "runmgr:" + m.addr
}
// SetInstanceTag 注入稳定实例身份与监听地址(组合根)。
func (m *Manager) SetInstanceTag(tag, addr string) {
m.instanceTag = tag
m.addr = addr
}
// ReleaseConversation 释放已抢到的槽位。启动失败时调用;重复释放无效果。
@@ -1145,7 +1280,27 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
go m.execute(rc, runner)
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
// 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。
if launch == nil {
rc.materialNote = m.materialIntake(rc)
}
go func() {
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
// failed 收口——不执行任何模型调用,副作用无从产生。
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner); err != nil {
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
if uErr, ok := err.(error); ok {
m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: uErr.Error()}})
}
m.emit(run.ConversationID, run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "执行预算排队已满或已取消"})
m.releaseConversation(run.ConversationID)
return
}
defer m.adm.release(m.st, run.ID)
m.execute(rc, runner)
}()
return run, nil
}
@@ -1154,6 +1309,17 @@ func (m *Manager) releaseConversation(conversationID string) {
m.convMu.Lock()
delete(m.convActive, conversationID)
m.convMu.Unlock()
if m.st != nil && m.st.Dialect() == "postgres" {
m.execMu.Lock()
epoch, ok := m.execEpoch[conversationID]
if ok {
delete(m.execEpoch, conversationID)
}
m.execMu.Unlock()
if ok {
m.st.ReleaseExecution(conversationID, m.InstanceID(), epoch)
}
}
}
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
@@ -1225,12 +1391,19 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
msg.VariantIndex = index
}
}
updated, err := m.st.SettleRun(&rc.run, msg)
updated, fenced, err := m.settleRunWithFence(rc, msg)
if err != nil {
log.Printf("[runmgr] settle run %s: %v", rc.run.ID, err)
m.failSettle(rc, err)
return
}
if fenced {
// 旧执行代次(U5/KTD4):租约已被他实例接管——本地不写终态;
// run 归属已移交,发围栏事件后静默退出(迟到副作用由此可见)。
log.Printf("[runmgr] settle run %s fenced: execution taken over (conv %s)", rc.run.ID, rc.run.ConversationID)
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.fenced", Message: "执行权已被其他实例接管"})
return
}
if m.OnUsage != nil && usage.TotalTokens > 0 {
m.OnUsage(rc.run.Owner, usage.TotalTokens)
}
@@ -1323,6 +1496,39 @@ func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, p
return m.events.emit(conversationID, runID, typ, payload)
}
// settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。
func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) {
if m.st.Dialect() != "postgres" {
updated, err = m.st.SettleRun(&rc.run, msg)
return updated, false, err
}
m.execMu.Lock()
epoch, ok := m.execEpoch[string(rc.run.ConversationID)]
m.execMu.Unlock()
if !ok {
// 未登记(旧路径起跑):按库内当前代次校验(同实例放行)。
if es, gerr := m.st.GetExecution(string(rc.run.ConversationID)); gerr == nil {
epoch = es.Epoch
}
}
return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch)
}
// RunActiveLocally run 是否在本实例活跃执行(U5:跨实例命令路由判定)。
func (m *Manager) RunActiveLocally(runID string) bool {
root := runID
if r, err := m.st.GetRunByID(runID); err == nil && r.ParentRunID != "" {
root = r.ParentRunID
}
m.mu.Lock()
_, ok := m.active[root]
if !ok {
_, ok = m.active[runID]
}
m.mu.Unlock()
return ok
}
// CancelRun 取消 run 及其子 run(ctx 级联)。
func (m *Manager) CancelRun(runID string) error {
r, err := m.st.GetRunByID(runID)
@@ -1375,6 +1581,16 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
if err != nil {
return "", false, err
}
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
// run 只能等 HITL 超时。此处提前返回未决错误,让命令总线转交执行实例。
m.mu.Lock()
_, local := m.active[run.ID]
m.mu.Unlock()
if !local {
return "", false, fmt.Errorf("runmgr: run %s not executing locally (cross-instance resolve goes through command bus)", run.ID)
}
kind := "choice"
answer := ""
@@ -1455,12 +1671,34 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen
if err != nil {
return core.AgentResultPayload{}, err
}
// 父子互等规避(U3,R8):同步子代理阻塞父 run——父先让出执行许可,子
// 获取自己的许可(与父同池计入全局预算),子完成后父重取(可能排队;
// 取消语境下重取失败仅记录,run 由取消路径收口)。
m.adm.release(m.st, string(parent.ID))
defer func() {
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner); err != nil {
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
}
}()
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner); err != nil {
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
return core.AgentResultPayload{}, err
}
defer m.adm.release(m.st, rc.run.ID)
return m.finishChild(rc, runner, true)
}
// startChild 做深度检查、建 run、发 spawn 事件。detach 时子 run 使用独立 ctx,
// 父 run 结束不会取消它;cancel 仍登记在 active 里,CancelRun 和停机中断照常生效。
func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID, name, task, hint string, detach bool) (*runCtx, runtime.Runner, error) {
return m.startChildOpts(parentCtx, parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}, detach)
}
// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。
func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) {
agentID, name, task, hint := o.agentID, o.name, o.task, o.hint
if parent.Kind == core.RunSubagent {
return nil, nil, fmt.Errorf("子代理不能再派发子代理")
}
@@ -1491,7 +1729,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
runner := m.subRunner
@@ -1577,44 +1815,220 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
return payload, nil
}
// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发
// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。
type bgSpawnOpts struct {
agentID, name, task, hint string
model string // "chat" = 子代理用对话同档模型(材料整理)
kv [][2]string
taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片)
material bool // 材料整理任务(通知回合衔接交付物选择)
queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝)
}
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
return m.spawnBackgroundOpts(parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint})
}
func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) {
if parent.Kind == core.RunSubagent {
return "", fmt.Errorf("子代理不能再派发子代理")
}
if o.taskKey != "" {
convID := string(parent.ConversationID)
m.materialMu.Lock()
active := m.materialActive != nil && m.materialActive[convID][o.taskKey]
m.materialMu.Unlock()
if active {
return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12))
}
}
conv := parent.ConversationID
m.noticeMu.Lock()
if m.bgInFlight == nil {
m.bgInFlight = map[string]int{}
}
if m.bgInFlight[parent.ConversationID] >= maxBackgroundChildren {
if m.bgInFlight[conv] >= maxBackgroundChildren {
if !o.queue {
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
}
if m.bgQueue == nil {
m.bgQueue = map[string][]bgQueueItem{}
}
m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o})
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
// 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位
// 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住:
// 下一次 decBackground 会尝试启动它。
card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren))
m.noticeMu.Lock()
if q := m.bgQueue[conv]; len(q) > 0 {
q[len(q)-1].card = card
}
m.noticeMu.Unlock()
return "", nil
}
m.bgInFlight[parent.ConversationID]++
m.bgInFlight[conv]++
m.noticeMu.Unlock()
return m.startBackgroundSlot(parent, o, core.ActivityCard{})
}
rc, runner, err := m.startChild(context.Background(), parent, agentID, name, task, hint, true)
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
if err != nil {
m.decBackground(parent.ConversationID)
return "", err
}
if o.taskKey != "" {
m.materialKeyRegister(string(parent.ConversationID), o.taskKey)
}
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
bgCard := preCard
if bgCard.ID == "" {
bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "")
} else {
m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID)
}
kv := o.kv
go func() {
defer m.decBackground(parent.ConversationID)
payload, _ := m.finishChild(rc, runner, false)
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: name, payload: payload})
if o.taskKey != "" {
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
}
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil {
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "后台子代理执行许可排队已满或已取消"})
m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err, kv)
return
}
defer m.adm.release(m.st, rc.run.ID)
payload, err := m.finishChild(rc, runner, false)
m.finishBackgroundCard(parent.Owner, bgCard, payload, err, kv)
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: o.name, payload: payload, material: o.material})
m.deliverNotices(parent.ConversationID)
}()
return rc.run.ID, nil
}
// pumpBackgroundQueue 槽位释放后按 FIFO 启动排队任务(decBackground 尾调)。
func (m *Manager) pumpBackgroundQueue(conv string) {
m.noticeMu.Lock()
if len(m.bgQueue[conv]) == 0 || m.bgInFlight[conv] >= maxBackgroundChildren {
m.noticeMu.Unlock()
return
}
item := m.bgQueue[conv][0]
m.bgQueue[conv] = m.bgQueue[conv][1:]
m.bgInFlight[conv]++
m.noticeMu.Unlock()
if _, err := m.startBackgroundSlot(item.parent, item.opts, item.card); err != nil {
log.Printf("[runmgr] background queue start: %v", err)
// 启动失败也要给排队卡一个终态,不能永久停在排队中。
m.finishBackgroundCard(item.parent.Owner, item.card, core.AgentResultPayload{}, err, item.opts.kv)
}
}
// createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil)
// 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。
// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时
// 覆盖默认状态行(排队态文案)。
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard {
if m.cards == nil {
return core.ActivityCard{}
}
title := strings.TrimSpace(name)
if title == "" {
title = "后台任务"
}
if status == "" {
status = "执行中,完成后自动回投结果"
}
body := []core.UINode{
{Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})},
}
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
Body: body,
}, "")
if err != nil {
log.Printf("[runmgr] background card create: %v", err)
return core.ActivityCard{}
}
return card
}
// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。
func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) {
if m.cards == nil || card.ID == "" {
return
}
card.SourceRunID = childID
for i, node := range card.Body {
if node.Type != core.UIKV {
continue
}
for j, kv := range node.KV {
if kv[0] == "状态" {
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
}
}
}
if err := m.cards.Update(owner, card); err != nil {
log.Printf("[runmgr] queued card promote: %v", err)
}
}
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
if m.cards == nil || card.ID == "" {
return
}
summary := strings.TrimSpace(payload.Summary)
tone := "ok"
if err != nil {
tone = "danger"
if summary == "" {
summary = err.Error()
}
summary = "执行失败:" + summary
}
if summary == "" {
summary = "(无结果摘要)"
}
card.Body = []core.UINode{
{Type: core.UIMarkdown, Markdown: summary},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})},
}
if uerr := m.cards.Update(owner, card); uerr != nil {
log.Printf("[runmgr] background card update: %v", uerr)
}
if _, serr := m.cards.SetStatus(owner, card.ID, core.CardDone, tone, ""); serr != nil {
log.Printf("[runmgr] background card status: %v", serr)
}
}
func (m *Manager) decBackground(convID string) {
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.bgInFlight == nil {
m.noticeMu.Unlock()
return
}
if m.bgInFlight[convID] > 0 {
m.bgInFlight[convID]--
}
m.noticeMu.Unlock()
// 槽位释放:按 FIFO 启动排队的系统任务(I3)。
m.pumpBackgroundQueue(convID)
}
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
@@ -1690,34 +2104,44 @@ func (m *Manager) deliverNotices(convID string) {
defer func() {
m.noticeMu.Lock()
delete(m.delivering, convID)
more := len(m.notices[convID]) > 0
m.noticeMu.Unlock()
if more {
m.deliverNotices(convID)
}
}()
if err := m.startNoticeRun(convID, items); err != nil {
// 迭代而非递归(U3 真库门禁实测):原实现的 defer 在投递失败重新入队后
// 同步再入 deliverNotices——持续失败(如会话忙/查库错误)时无限自递归,
// test-pg 慢速时序下栈溢出(job 2352)。失败本轮即返回:队列保留,
// 由下一次 enqueueNotice 或重启重投驱动;投递期间新到的通知由循环
// 尾部再取,不再经 defer 递归。
for {
if err := m.startNoticeRun(convID, items); err != nil {
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
m.notices[convID] = append(items, m.notices[convID]...)
m.noticeMu.Unlock()
return
}
// 投递成功才删持久化行(失败保留下次重投)。
ids := make([]string, 0, len(items))
for _, n := range items {
if n.id != "" {
ids = append(ids, n.id)
}
}
if err := m.st.DeleteNotices(ids); err != nil {
log.Printf("[runmgr] notice delete: %v", err)
}
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
items = append([]bgNotice{}, m.notices[convID]...)
if len(items) == 0 {
m.noticeMu.Unlock()
return
}
m.notices[convID] = append(items, m.notices[convID]...)
delete(m.notices, convID)
m.noticeMu.Unlock()
return
}
// 投递成功才删持久化行(失败保留下次重投)。
ids := make([]string, 0, len(items))
for _, n := range items {
if n.id != "" {
ids = append(ids, n.id)
}
}
if err := m.st.DeleteNotices(ids); err != nil {
log.Printf("[runmgr] notice delete: %v", err)
}
}
func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
if len(items) == 0 {
return nil
@@ -1736,6 +2160,25 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
for _, n := range items {
fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary))
}
// 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。
// runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。
materialSeen := false
for _, n := range items {
if n.material {
materialSeen = true
break
}
}
if materialSeen {
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
if m.genuiAuto != nil {
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
b.WriteString("\n")
b.WriteString(genUIAutoReminderText())
b.WriteString("\n")
}
}
}
b.WriteString("</system-reminder>")
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
@@ -1839,13 +2282,34 @@ func partsText(parts []core.Part) string {
var sb strings.Builder
for _, p := range parts {
if p.Type == core.PartText {
sb.WriteString(p.Text)
text := p.Text
// MiniMax 等模型把推理写成行内 <think> 块:子代理摘要/回投卡正文
// 只取 think 之外的正文(实测 <think> 前缀泄漏进收件箱卡片与通知)。
if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "</think>") {
text = body
}
sb.WriteString(text)
sb.WriteString("\n")
}
}
return strings.TrimSpace(sb.String())
}
// stripInlineThink 去掉文本中的 <think>…</think> 段(完整闭合才剥,半截保守保留)。
func stripInlineThink(text string) string {
for {
start := strings.Index(text, "<think>")
if start < 0 {
return text
}
end := strings.Index(text[start:], "</think>")
if end < 0 {
return text
}
text = text[:start] + text[start+end+len("</think>"):]
}
}
func artifactSummary(parts []core.Part) string {
var bits []string
seen := map[string]bool{}
+10
View File
@@ -128,6 +128,16 @@ func (m *Manager) ResumeInterruptedChildren() {
return
}
for _, run := range runs {
// 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold
// 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。
if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held {
if herr != nil {
log.Printf("[runmgr] resume skip %s: recovery hold 判定失败(fail-closed): %v", run.ID, herr)
} else {
log.Printf("[runmgr] resume skip %s: recovery hold 未解除(人工核对后显式释放才会续跑)", run.ID)
}
continue
}
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
continue
+180
View File
@@ -0,0 +1,180 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 双速提醒经内核 Reminders 钩子进入下一请求的消息(不是响应部件):
// 这里捕获每次模型请求的原文,检索提醒标记。
type dualSpeedRecorder struct {
clock *stepClock
turns [][]agentllm.Event
request []string
base agentllm.StreamFunc
}
// stepClock 每 tick 前进 5 秒;runDirect 起 0 时刻,模型每被调用一次 tick 一格,
// 20 次调用即 100 秒量级,足以压过默认 75 秒阈值。
type stepClock struct{ step int64 }
func (c *stepClock) now() time.Time { return time.Unix(0, c.step*int64(5*time.Second)) }
func (c *stepClock) tick() { c.step++ }
func (rec *dualSpeedRecorder) stream(base agentllm.StreamFunc) agentllm.StreamFunc {
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
rec.clock.tick()
for _, m := range msgs {
if b, err := json.Marshal(m); err == nil {
rec.request = append(rec.request, string(b))
}
}
return base(ctx, model, msgs, tools, opts)
}
}
func (rec *dualSpeedRecorder) nudged() bool {
for _, r := range rec.request {
if strings.Contains(r, "任务快慢双速") {
return true
}
}
return false
}
func runDualSpeed(t *testing.T, turns [][]agentllm.Event, setup func(rc *fakeRunCtx)) *dualSpeedRecorder {
t.Helper()
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}
if setup != nil {
setup(rc)
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
return rec
}
func toolOnlyTurns(n int) [][]agentllm.Event {
var turns [][]agentllm.Event
for i := 0; i < n; i++ {
// 每次调用用独立 ID(toolCall 帮手的 ID 固定,重复会撞内核的工具槽位)。
call := agentllm.ToolCall{ID: fmt.Sprintf("c-ds-%d", i), Name: "make_report", Arguments: "{}"}
turns = append(turns, textDeltaTurn("", call))
}
return turns
}
// 长时间纯工具轮、用户零答复 → 阈值后恰好注入一次双速提醒。
func TestDualSpeedNudgeFiresOnceAfterThreshold(t *testing.T) {
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, nil)
if !rec.nudged() {
t.Fatal("dual-speed nudge missing after threshold with no user reply")
}
count := 0
for _, r := range rec.request {
count += strings.Count(r, "任务快慢双速")
}
if count > 1 {
t.Fatalf("nudge injected %d times, want at most once per request window", count)
}
}
// 短任务(阈值内完成)不提醒。
func TestDualSpeedNudgeQuietBeforeThreshold(t *testing.T) {
turns := append(toolOnlyTurns(1), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, nil)
if rec.nudged() {
t.Fatal("nudge fired before the threshold")
}
}
// 设置 runtime.dualSpeedNudgeSeconds=0 → 关闭,超时也不提醒。
func TestDualSpeedNudgeDisabledBySetting(t *testing.T) {
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
})
if rec.nudged() {
t.Fatal("nudge fired although disabled via runtime.dualSpeedNudgeSeconds=0")
}
}
// 模型已给出面向用户的答复(快通道成立)→ 即使后续事实核查续写拖过阈值也不提醒。
func TestDualSpeedNudgeSkippedWhenReplied(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("完整总结:产出物与要点。"),
textDeltaTurn("已修正并重新登记。"),
}
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.verify = true
rc.execArtifact = &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"}
rc.execArtifactContent = []byte("# 报告\n\n准确率 95%。")
})
if rec.nudged() {
t.Fatal("nudge fired although the model already gave its user reply")
}
}
// 已把深度工作放后台(慢通道成立)→ 不再提醒。
func TestDualSpeedNudgeSkippedWhenDelegated(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("delegate_task", `{"name":"深度解析","task":"逐页解析","background":true}`)),
}
turns = append(turns, toolOnlyTurns(19)...)
turns = append(turns, textDeltaTurn("后台进行中,先给结论。"))
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.bgID = "run_bg_1"
})
if rec.nudged() {
t.Fatal("nudge fired although background delegation already happened")
}
}
// 首次被无视后按 45s 间隔升级重复(最多 3 次),第二次起为硬性措辞。
func TestDualSpeedNudgeEscalates(t *testing.T) {
turns := append(toolOnlyTurns(35), textDeltaTurn("完成。")) // 35×5s=175s → 75s 起、120s、165s 三档
rec := runDualSpeed(t, turns, nil)
marks, hard := 0, 0
for _, r := range rec.request {
marks += strings.Count(r, "用户已经等待")
hard += strings.Count(r, "硬性要求")
}
if marks < 1 {
t.Fatalf("escalation missing: requests=%d", len(rec.request))
}
if marks > 2 {
t.Fatalf("escalated %d times, want at most 2 repeats after the first", marks)
}
}
// 入口前置:带附件的任务第 0 回合注入快慢双速选项;无附件不注入;阈值关闭不注入。
func TestDualSpeedEntryHintWithAttachments(t *testing.T) {
withAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
})
if !strings.Contains(strings.Join(withAtt.request, "\n"), "快慢双速") {
t.Fatal("entry hint missing for an attachment-bearing task")
}
noAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, nil)
if strings.Contains(strings.Join(noAtt.request, "\n"), "本任务附带材料") {
t.Fatal("entry hint fired without attachments")
}
disabled := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
})
if strings.Contains(strings.Join(disabled.request, "\n"), "本任务附带材料") {
t.Fatal("entry hint fired although the nudge setting is disabled")
}
}
+112
View File
@@ -0,0 +1,112 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
core "agent-system-core"
)
// ---- E4:R4 升级提醒 ----
// 滑动窗口判据:近 6 次结果中 3 次失败(可与成功交错)触发换路指令;
// 每 run 一次;2 次失败不触发。
func TestEscalationOnConsecutiveToolFailures(t *testing.T) {
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error")
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error")
if got := st.escalationReminders(); len(got) != 0 {
t.Fatalf("two scattered failures must not escalate: %v", got)
}
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error") // 窗口内第 3 败(交错分布)
got := st.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "停止原样重试") {
t.Fatalf("three failures in the window must escalate: %v", got)
}
if again := st.escalationReminders(); len(again) != 0 {
t.Fatalf("escalation must fire once per run: %v", again)
}
}
// 检索连续空结果(成功但无实质内容)触发「换关键词或如实写缺失」。
func TestEscalationOnZeroHitSearches(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "未找到相关内容"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("knowledge_search", `{"query":"a"}`), toolCall("knowledge_search", `{"query":"b"}`)),
textDeltaTurn("好了。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st.noteEvidenceCall("knowledge_search", "ok", "未找到相关内容")
if got := st.escalationReminders(); len(got) != 0 {
t.Fatalf("one zero hit must not escalate: %v", got)
}
st.noteEvidenceCall("knowledge_search", "ok", "无结果")
got := st.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "材料中无此信息") {
t.Fatalf("two zero hits must escalate: %v", got)
}
// 有实质结果后计数重置。
st.noteEvidenceCall("knowledge_search", "ok", "营收 3,200 万元")
if st.zeroHitSearches != 0 {
t.Fatal("substantive hit must reset zero-hit count")
}
}
// ---- E4:结构化 source_id 账本 ----
func TestEvidenceLedgerTagsSource(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "上半年营收 3,200 万元。"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("knowledge_search", `{"query":"营收"}`)),
textDeltaTurn("总结完成。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
// 结果文本必须带来源标记,且账本登记了对应条目(直接经 runTool 断言)。
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("knowledge_search", `{}`)
if !strings.Contains(res.Content, "【src_1|knowledge_search】") {
t.Fatalf("tool result must carry the source tag: %.120s", res.Content)
}
if len(st.ledger) != 1 || st.ledger[0].ID != "src_1" || !strings.Contains(st.ledger[0].Text, "3,200") {
t.Fatalf("ledger = %+v", st.ledger)
}
// 第二次登记递增编号。
res2 := st.runTool("fs_read_file", `{}`)
if !strings.Contains(res2.Content, "【src_2|fs_read_file】") {
t.Fatalf("second tag must be src_2: %.120s", res2.Content)
}
}
// 非证据工具不登记、不加标记。
func TestEvidenceLedgerSkipsNonEvidenceTools(t *testing.T) {
f := &fakeRunCtx{}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("time_now", `{}`)
if strings.Contains(res.Content, "src_") || len(st.ledger) != 0 {
t.Fatalf("non-evidence tool must not enter the ledger: %.80s %+v", res.Content, st.ledger)
}
}
// 材料读(inputs/)打标;模型自产文件的读与 grep 不打——尾注会污染
// read→edit→build 编辑循环(E4 首轮 eval 实测教训,回归锚点)。
func TestEvidenceLedgerOnlyTagsMaterialReads(t *testing.T) {
if !isEvidenceRead("read_file", json.RawMessage(`{"path":"inputs/年报.docx.md"}`)) {
t.Fatal("inputs/ read must count as evidence")
}
if isEvidenceRead("read_file", json.RawMessage(`{"path":"方案.md"}`)) {
t.Fatal("own-workspace read must NOT count as evidence")
}
if isEvidenceRead("grep", json.RawMessage(`{"pattern":"营收"}`)) {
t.Fatal("grep must never count as evidence")
}
}
+218 -3
View File
@@ -1,6 +1,9 @@
package runtime
import (
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
@@ -9,6 +12,10 @@ import (
"agent-system-backend/internal/quality"
)
// factCheckMaxRounds 是 E3 的事实核查修正轮上限:两轮(第二轮为「最后一次
// 拦截」+标注出路),之后放行并记检查行。
const factCheckMaxRounds = 2
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
@@ -44,7 +51,9 @@ func ExtractFacts(text string) []string {
return out
}
// UnverifiedFacts 返回材料里找不到出处的 token(白名单:豁免的通用口径词)。
// UnverifiedFacts 返回材料里找不到出处的 token。放行三类:豁免的通用口径词、
// 材料中能回溯的、以及 E3 标注感知——文中以「目标/预计/假设」等标注词显式
// 标记为规划量的数字不再是凭空事实,机械溯源放行(MarkedAsEstimate)。
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
return nil
@@ -54,13 +63,46 @@ func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool)
if exempt[tok] {
continue
}
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) {
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) && !MarkedAsEstimate(artifactText, tok) {
out = append(out, tok)
}
}
return out
}
// factMarkingRe:数字邻域出现这些词即视为「已标注的规划量」。选取保守集合,
// 避免把「会议目标」之类的普通动词误当标注(需要与数字同现于邻域内)。
var factMarkingRe = regexp.MustCompile(`目标|假设|预计|预估|推测|估算|规划|拟定|建议值|上限|下限|阈值|基准值|参考值|暂定`)
// factClauseCut 是分句分隔符:标注判定以「句」为单位而不是固定窗口——
// 固定窗口会把相邻句的标注词错标给前一个数字(实测:「覆盖 3,500 用户,
// 目标 85%」里 3,500 被误放行)。
const factClauseCut = "。;!?\n,、;"
// MarkedAsEstimate 报告 token 在 text 中的任一出现是否与标注词同句
// (同句=两侧最近分隔符之间,括号不算分隔,覆盖「目标完成率 85%」「85%
// (预计)」)。供终轮核查与评测端共用同一口径。
func MarkedAsEstimate(text, tok string) bool {
from := 0
for {
i := strings.Index(text[from:], tok)
if i < 0 {
return false
}
i += from
lo := strings.LastIndexAny(text[max(0, i-64):i], factClauseCut) + max(0, i-64) + 1
if end := strings.IndexAny(text[i:], factClauseCut); end >= 0 {
clause := text[lo : i+end]
if factMarkingRe.MatchString(clause) {
return true
}
} else if factMarkingRe.MatchString(text[lo:]) {
return true
}
from = i + len(tok)
}
}
func stripPunct(s string) string {
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
}
@@ -82,7 +124,7 @@ func factCheckNotice(tokens []string) string {
}
b.WriteString(tok)
}
b.WriteString("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
b.WriteString("。逐条二选一:改成材料或来源结果(检索/读取输出带【src_N|…】标记)里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
return b.String()
}
@@ -160,3 +202,176 @@ func factExemptions() map[string]bool {
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
}
}
// factCheckGate 决定本轮未核实 token 的处置(E3):
// - 第一轮:给修正通知并续跑;
// - 第二轮(仅当 token 集合相对上一轮有变化——模型确实改过但仍有残留):
// 「最后一次拦截」+ 标注出路;
// - token 集合与上一轮完全相同(模型没有理会)或轮数用尽:放行,
// 由调用方记入检查行如实呈现。续写预算是各终轮门禁共享的,
// 无变化的重复拦截只会挤占预算(实测会挤掉后续门禁的修正机会)。
func (st *directRun) factCheckGate(tokens []string) (notice string, cont bool) {
same := st.factCheckPrev != nil && len(tokens) == len(st.factCheckPrev)
if same {
for _, tok := range tokens {
if !st.factCheckPrev[tok] {
same = false
break
}
}
}
if st.factCheckRounds >= factCheckMaxRounds || same {
return "", false
}
st.factCheckRounds++
st.factCheckPrev = map[string]bool{}
for _, tok := range tokens {
st.factCheckPrev[tok] = true
}
notice = factCheckNotice(tokens)
if st.factCheckRounds > 1 {
notice = "最后一次拦截:" + notice + " 若该数字确属规划量而非事实,必须在文中用「目标 / 预计 / 假设」等词显式标注(如「目标 85%」);既无出处又不标注的数字不得保留。"
}
return notice, true
}
// ---- E4:证据账本与 R4 升级提醒 ----
// evidenceEntry 是账本的一条来源记录(正文截头保存;全文在转录与工具证据里)。
type evidenceEntry struct {
ID string
Tool string
Text string
}
// evidenceTools 无条件登记进 source_id 账本的证据类工具(检索面)。
// 内核工作区 read_file 不在此列:它也读模型自己的工作文件——给自身文件
// 的读结果尾注【src_N】会污染 read→edit→build 的编辑循环(实测 E4 首轮
// eval:模型视尾注为文件被外改动,反复重读重试,工具失败 10→24、调用
// 63→166)。仅当读取 inputs/ 材料时才登记(isEvidenceRead)。
var evidenceTools = map[string]bool{
"knowledge_search": true,
"web_search": true,
"tavily_search": true,
"fs_read_file": true,
}
// isEvidenceRead 判定一次工作区读是否为「材料读」:仅 inputs/(上传材料
// 落点,环境段列出)之下的 read_file 计入账本;grep 无法区分材料与自产
// 文件,一律不计。
func isEvidenceRead(name string, args json.RawMessage) bool {
if name != "read_file" {
return false
}
var obj map[string]string
if err := json.Unmarshal(args, &obj); err != nil {
return false
}
p := strings.TrimSpace(obj["path"])
p = strings.TrimPrefix(p, "./")
return strings.HasPrefix(p, "inputs/") || strings.HasPrefix(p, "/inputs/")
}
// R4 换路提醒的滑动窗口判据:最近 escalationWindow 次工具结果里出现
// escalationFailureThreshold 次失败即触发(每 run 一次)。用窗口而不是
// 「连续 N 次」——实测失败多与成功交错(20 失败散布在 108 次调用里,
// 连续计数从未达 2,首轮 eval 生产路径零触发),滑动窗口才抓得住
// 「整体在原地打转」的形态。
const (
escalationWindow = 6
escalationFailureThreshold = 3
// 检索空命中阈值独立取 2:检索单次成本高,两次落空即是换维度信号。
escalationZeroHitThreshold = 2
)
// tagSource 把一次成功的证据工具输出登记进账本,返回给模型看的来源标记
// (如 src_3|knowledge_search)。并行执行下由 toolMu 串行化计数与追加。
func (st *directRun) tagSource(tool, text string) (string, bool) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.ledgerCount++
id := fmt.Sprintf("src_%d", st.ledgerCount)
head := []rune(text)
if len(head) > 2000 { // 账本只存索引性头部,全文在转录/证据里
head = head[:2000]
}
st.ledger = append(st.ledger, evidenceEntry{ID: id, Tool: tool, Text: string(head)})
return id + "|" + tool, true
}
// noteEvidenceCall 维护 R4 的检索空命中计数(成功但无实质内容的证据类
// 工具调用)。工具成败计数不在这里——并行下以 onEvent 终点的槽位状态为
// 单一事实源(noteFailureOutcome),避免两处重复计数。
func (st *directRun) noteEvidenceCall(name, status, output string) {
if status != "ok" {
return
}
st.toolMu.Lock()
defer st.toolMu.Unlock()
trimmed := strings.TrimSpace(output)
if evidenceTools[name] && (trimmed == "" || containsAny(trimmed, zeroHitMarkers)) {
st.zeroHitSearches++
} else if evidenceTools[name] {
st.zeroHitSearches = 0
}
}
// zeroHitMarkers 是检索结果「无命中」的常见表述(按工具输出原文判别)。
var zeroHitMarkers = []string{"未找到", "没有找到", "无结果", "无相关", "暂无匹配", "No results"}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// escalationReminders 返回待注入的换路提醒并清标记(每类条件一次,
// 连续失败再次触发时会重新提醒——失败计数在成功后重置,streak 标记随重置)。
// 文案直接给动作:MiniMax 对泛泛提醒不敏感(实测),对具体指令照办。
func (st *directRun) escalationReminders() []string {
var out []string
st.toolMu.Lock()
fails, failNoted := countFails(st.recentOutcomes), st.failWindowNoted
zero, zeroNoted := st.zeroHitSearches, st.zeroHitNoted
if fails >= escalationFailureThreshold && !failNoted {
st.failWindowNoted = true
}
if zero >= escalationZeroHitThreshold && !zeroNoted {
st.zeroHitNoted = true
}
st.toolMu.Unlock()
if fails >= escalationFailureThreshold && !failNoted {
log.Printf("[runtime] r4 escalation reminder injected (tool failures %d in last %d outcomes)", fails, escalationWindow)
out = append(out, fmt.Sprintf("工具最近集中失败(近 %d 次调用中多次报错):停止原样重试。逐条重读错误信息,改参数、换工具或换路径再试;确认无法绕过时,如实在答复里说明卡点与已尝试的手段,不要编造结果。", fails))
}
if zero >= escalationZeroHitThreshold && !zeroNoted {
log.Printf("[runtime] r4 escalation reminder injected (zero-hit searches %d)", zero)
out = append(out, "检索已连续多次无结果:换关键词/换检索维度再试一次;仍无结果就如实写「材料中无此信息」,不要凭印象补数字。")
}
return out
}
// noteFailureOutcome 供 onEvent 在工具结束时记录成败(并行下事件乱序,
// 以 runToolCtx 已写入槽位的 status 为准)。维护最近 escalationWindow 次
// 结果的滑动窗口。
func (st *directRun) noteFailureOutcome(status string) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.recentOutcomes = append(st.recentOutcomes, status == "error")
if len(st.recentOutcomes) > escalationWindow {
st.recentOutcomes = st.recentOutcomes[len(st.recentOutcomes)-escalationWindow:]
}
}
func countFails(outcomes []bool) int {
n := 0
for _, failed := range outcomes {
if failed {
n++
}
}
return n
}
+61 -1
View File
@@ -23,7 +23,7 @@ func TestUnverifiedFactsAndNotice(t *testing.T) {
t.Fatalf("sourced figure must not be reported: %v", got)
}
notice := factCheckNotice(got)
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料里") {
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料或来源结果") {
t.Fatalf("notice malformed: %q", notice)
}
if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil {
@@ -63,3 +63,63 @@ func TestFactCheckContinuationOnce(t *testing.T) {
}
_ = turns2
}
// ---- E3:标注感知豁免 ----
func TestMarkedAsEstimate(t *testing.T) {
cases := []struct {
name, text, tok string
want bool
}{
{"前置标注", "目标完成率 85%,分三个阶段推进", "85%", true},
{"后置标注", "完成率 85%(预计)", "85%", true},
{"裸数字", "当前完成率 85%,尚有差距", "85%", false},
{"邻域外有标注词", "目标是建成样板。完成率 85%", "85%", false},
{"多次出现其一标注", "实际 70%。目标 85% 年内达成", "85%", true},
{"推测标注", "用户规模约 120,000(推测)", "120,000", true},
}
for _, c := range cases {
if got := MarkedAsEstimate(c.text, c.tok); got != c.want {
t.Errorf("%s: MarkedAsEstimate(%q) = %v, want %v", c.name, c.tok, got, c.want)
}
}
}
func TestUnverifiedFactsMarksPass(t *testing.T) {
material := "材料:上半年营收 3,200 万元。"
// 「目标 85%」已标注 → 放行;裸 95% → 仍拦。
artifact := "上半年营收 3,200 万元,达成率目标 85%,试点扩面后裸数字 95%。"
got := UnverifiedFacts(artifact, material, factExemptions())
if len(got) != 1 || got[0] != "95%" {
t.Fatalf("unverified = %v(应只拦未标注的 95%%)", got)
}
}
// ---- E3:两轮修正的轮次逻辑 ----
func TestFactCheckGateRounds(t *testing.T) {
st := &directRun{}
first := []string{"95%", "10月20日"}
// 第一轮:拦截并给修正通知。
notice, cont := st.factCheckGate(first)
if !cont || !strings.Contains(notice, "95%") || strings.Contains(notice, "最后一次拦截") {
t.Fatalf("round1: cont=%v notice=%q", cont, notice)
}
// token 完全没变(模型没理会):不再拦——重复拦截只挤占共享续写预算。
if _, cont := st.factCheckGate(first); cont {
t.Fatal("unchanged tokens must pass through")
}
// token 有变化(改了一个、残留一个):第二轮升级为「最后一次拦截」。
notice, cont = st.factCheckGate([]string{"10月20日"})
if !cont || !strings.Contains(notice, "最后一次拦截") || !strings.Contains(notice, "目标") {
t.Fatalf("round2: cont=%v notice=%q", cont, notice)
}
// 轮数用尽:放行。
if _, cont := st.factCheckGate([]string{"7%"}); cont {
t.Fatal("rounds exhausted must pass through")
}
if st.factCheckRounds != 2 {
t.Fatalf("rounds = %d", st.factCheckRounds)
}
}
+97
View File
@@ -0,0 +1,97 @@
package runtime
import (
"context"
"strings"
"testing"
core "agent-system-core"
)
// 问题一回归(run_b520bf2e3037):事实核查修正轮触发时,现场那段正式总结必须
// 降级进过程时间线;修正轮的收尾才是最终答复——否则前端按「最后一个过程部件」
// 切分后,用户只看到修正流水账,看不到原总结。
func TestFactCheckDemotesPriorSummary(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}},
verify: true,
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
}
turns := padTurns(
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("已完成。## 产出物\n\n结构完整的用户总结。"),
textDeltaTurn("已修正并重新登记。"),
)
r := &LLMRunner{Stream: scriptStream(turns, nil), VerifyStream: passedStream()}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var summary *core.Part
visible := []string{}
sawFactCheck := false
for i := range rc.assistant {
p := rc.assistant[i]
if p.Type == core.PartText && strings.Contains(p.Text, "结构完整的用户总结") {
summary = &rc.assistant[i]
}
if p.Type == core.PartText && !p.Process {
visible = append(visible, p.Text)
}
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
sawFactCheck = true
}
}
if summary == nil {
t.Fatal("prior summary text missing")
}
if !summary.Process {
t.Fatalf("prior summary not demoted to process: %+v", *summary)
}
if !sawFactCheck {
t.Fatal("fact check note missing")
}
if len(visible) != 1 || visible[0] != "已修正并重新登记。" {
t.Fatalf("visible final texts = %v, want only the continuation closing", visible)
}
}
// 同源跳过提示不再作为用户可见正文追加(原先经 StopDecision.Notice 顶掉正式总结),
// 改记为通过态检查行留在过程时间线。
func TestVerifierSkipIsCheckNotFinalText(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, // 轻量回落=主模型 → 同源跳过
verify: true,
}
turns := padTurns(textDeltaTurn("总结完成。"))
r := &LLMRunner{Stream: scriptStream(turns, nil)} // 无 VerifyStream
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
skipCheck, noticeText := false, false
visible := []string{}
for _, p := range rc.assistant {
if p.Check != nil && p.Check.Passed && strings.Contains(p.Check.Reason, "同源模型验证") {
skipCheck = true
}
if p.Type == core.PartText {
if strings.Contains(p.Text, verifierUnavailableNotice) {
noticeText = true
}
if !p.Process {
visible = append(visible, p.Text)
}
}
}
if !skipCheck {
t.Fatal("same-source skip not recorded as a passing check")
}
if noticeText {
t.Fatal("skip notice leaked into message text parts")
}
if len(visible) != 1 || visible[0] != "总结完成。" {
t.Fatalf("visible final texts = %v", visible)
}
}
+15 -4
View File
@@ -67,6 +67,8 @@ const (
toolCardBegin = "card_begin"
toolCardAppend = "card_append"
toolCardCommit = "card_commit"
// confirm_choice 发起待确认选择卡(材料整理完成后选交付物;I2)。
toolConfirmChoice = "confirm_choice"
)
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
@@ -242,7 +244,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
if stream == nil {
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
// 此处不再显式包装(防双重)。
client := openLLMClient(route.Base, route.Key)
client := openLLMClient(route.Base, route.Key, route.Model)
stream = client.Stream
}
@@ -270,6 +272,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
Surfaces: rc.Surfaces(),
ThinkReplay: thinkReplayEnabled(eff, route.Model),
})
tail := ctxasm.Tail{
@@ -307,10 +310,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)})
}
decls = append(decls,
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回,子代理完成后系统会自动发起一轮汇报"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行,当前回合不等待结果"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回——这是重任务的慢通道:先在当前回复里给用户即时反馈(相关性判断/概要/计划),深度工作放后台;系统会在收件箱建进度卡并在完成后回投结果、自动发起一轮汇报。预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理)优先用此模式,不要让用户干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行(慢通道),当前回合不等待结果;收件箱有进度卡,完成后自动汇报"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolReview, Description: l.toolDesc(prof.Owner, toolReview, "对一段材料做评审:多个审查子代理并行找问题,再逐条独立验证,只保留验证通过的问题"), Parameters: json.RawMessage(`{"type":"object","properties":{"target":{"type":"string","description":"要评审的文本或工件"},"focus":{"type":"string","description":"评审重点"},"reviewers":{"type":"integer","minimum":1,"maximum":3,"description":"并行审查人数,1 到 3,缺省 1"}},"required":["target"]}`)},
agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
agentllm.ToolDecl{Name: toolConfirmChoice, Description: l.toolDesc(prof.Owner, toolConfirmChoice, "向用户发起待确认选择卡并等待答复:3-4 个选项(各附一句依据与影响)+ 可选自由输入。材料整理完成后选正式交付物、或任何需要用户拍板的分叉都用它,不要在文本里罗列选项后干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string","description":"要用户决定的问题,一句话"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","description":"选项名,如:会议摘要"},"description":{"type":"string","description":"一句依据(来自材料或上下文)"},"impact":{"type":"string","description":"选择后的产出与耗时影响"},"recommended":{"type":"boolean","description":"最贴合的一项设 true"}},"required":["id","label","description","impact"]}},"free_input":{"type":"boolean","description":"允许自由输入其他要求,默认 true"},"timeout_sec":{"type":"integer","description":"等待上限秒数,0=不限"}},"required":["question","options"]}`)},
)
if eff.GetString("runtime.entry", "direct") != "squad" {
decls = append(decls, agentllm.ToolDecl{
@@ -488,8 +492,15 @@ func agentNameOf(prof core.AgentProfile) string {
}
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
func openLLMClient(base, key string) *agentllm.Client {
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
func openLLMClient(base, key, model string) *agentllm.Client {
c := &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
// MiniMax 系(interleaved thinking)的 OpenAI 兼容接口默认把思考混在
// content 的 <think> 块里;reasoning_split=true 让思考改走独立
// reasoning_content 字段(SSE 捕获路径不变,正文流不再夹带思考)。
if strings.HasPrefix(model, "MiniMax-M") {
c.ReasoningSplit = true
}
return c
}
// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。
+331 -45
View File
@@ -49,7 +49,8 @@ type directRun struct {
responding bool
acting bool
queued []agentllm.ToolCall
inflight *toolSlot
inflight map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
turnBody string
evidence []toolEvidence
@@ -59,12 +60,41 @@ type directRun struct {
checklistTouched bool
toolTurnsSince int
nudgedPlanless bool
factChecked bool
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
factCheckRounds int // E3:事实核查修正轮计数(上限 factCheckMaxRounds)
factCheckPrev map[string]bool // E3:上一轮的未核实 token 集合(未变化则不再拦)
// E4(R4 升级规则):harness 侧触发的换路提醒——不依赖模型自觉发现
// 自己在原地打转。成败计数在 onEvent 终点统一维护(noteFailureOutcome),
// 达阈值注入一次换路指令;检索连续空结果同理(noteEvidenceCall)。
recentOutcomes []bool // E4(R4):最近 N 次工具成败(true=失败),滑动窗口
failWindowNoted bool // E4(R4):窗口判据已提醒(每 run 一次)
zeroHitSearches int
zeroHitNoted bool
// E4(结构化 source_id 账本):证据类工具的成功结果登记为带 ID 的
// 条目,工具输出尾注【src_N|工具】标记——数字溯源的「材料/来源」
// 从此有可指地址。
ledger []evidenceEntry
ledgerCount int
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
// 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点,
// nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。
started time.Time
nudgedDualSpeedCount int
replied bool
delegatedBackground bool
materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险)
// 交付物确认链(I2):confirm_choice 发起过选择卡;用户答复后
// pendingDeliverable 非空——回合结束仍未委派则硬性续跑一轮。
confirmIssued bool
pendingDeliverable string
deliverableNudged bool
// 材料整理通知回合(历史含「材料整理」完成标记):结束仍无选择卡且未委派
// 时,硬性要求发起 confirm_choice(弱模型应急,一次性)。
materialNotice bool
materialNoticeNudged bool
}
type toolEvidence struct {
@@ -97,7 +127,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
}
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow, started: l.now(), inflight: map[string]*toolSlot{}}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
st.memoryStore = ms.StoreMemory()
@@ -113,6 +143,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
st.pinSubagentArtifacts(text)
}
st.servedModel = model
// 材料整理通知回合识别(I2):历史含「材料整理」完成标记的系统提醒——
// 该回合负有发起交付物选择卡的职责,stop 钩子据此兜底。扫会话历史而非
// 组装后的 msgs:系统提示词块会撑大 msgs,窗口不稳定。
histMsgs := rc.History()
for i := len(histMsgs) - 1; i >= 0 && i >= len(histMsgs)-8; i-- {
if histMsgs[i].Role != core.RoleSystem {
continue
}
for _, p := range histMsgs[i].Parts {
if p.Type == core.PartText && strings.Contains(p.Text, materialNoticeMarker) {
st.materialNotice = true
break
}
}
if st.materialNotice {
break
}
}
stream = agentllm.WithFallback(stream, ChatFallbacks(eff), st.noteModelSwitch)
defer func() {
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
@@ -127,7 +175,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
for _, d := range decls {
name := d.Name
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
return st.runTool(name, string(args))
return st.runToolCtx(ctx, name, string(args))
}})
}
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
@@ -153,7 +201,14 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
wsReg := ws
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
res := wsReg.Execute(ctx, name, args)
st.noteKernelResult(res)
st.noteKernelResult(ctx, res)
// E4:工作区读仅在 inputs/ 材料读时登记账本(isEvidenceRead;
// 自产文件的读结果不打标,防编辑循环被尾注污染——实测教训)。
if !res.IsError && (evidenceTools[name] || isEvidenceRead(name, args)) && strings.TrimSpace(res.Content) != "" {
if tag, ok := st.tagSource(name, res.Content); ok {
res.Content += "\n【" + tag + "】"
}
}
return res
}})
}
@@ -267,6 +322,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
})
res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: model,
ToolExecution: toolExecMode(eff),
SerialTools: serialTools,
History: history,
Session: sessStore,
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
@@ -286,6 +343,22 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
Reminders: func(turn int) []string {
var out []string
out = append(out, st.reminders(turn)...)
// 快慢双速入口前置(SR-B10 实路径):带附件的任务在第 0 回合就把
// 双速选项摆给模型——模型对开场指令的遵循远好于中途提醒(实测
// MiniMax 无视中途 system-reminder,但对工作方法开场指令照办)。
// 材料整理入口(附件即保证):系统已确定性启动时注入事实说明
// (职责改为快通道反馈+后续交付物),未预启动(文字+小材料)时
// 保留建议式提示,回合结束由 stop 钩子兜底保证。
if turn == 0 && st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds) > 0 && len(rc.Attachments()) > 0 {
note := ""
if src, ok := rc.(interface{ MaterialNote() string }); ok {
note = strings.TrimSpace(src.MaterialNote())
}
if note == "" {
note = "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。"
}
out = append(out, note)
}
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
if sessStore != nil && !seedRun && turn == 0 {
@@ -367,20 +440,41 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
func (st *directRun) noteKernelResult(res agenttools.Result) {
if st.inflight == nil {
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
call, ok := nruntime.ToolCallFromContext(ctx)
slot := st.slotFor("")
if ok {
slot = st.slotFor(call.ID)
}
if slot == nil {
return
}
summary := summarize(res.Content, nil)
if res.IsError {
summary = "error: " + summary
}
st.inflight.summary = summary
st.inflight.status = statusOf(nil)
slot.summary = summary
slot.status = statusOf(nil)
if res.IsError {
st.inflight.status = "error"
slot.status = "error"
}
st.inflight.verifyText = tailRunes(res.Content, 600)
slot.verifyText = tailRunes(res.Content, 600)
}
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
func (st *directRun) slotFor(callID string) *toolSlot {
st.toolMu.Lock()
defer st.toolMu.Unlock()
if callID != "" {
return st.inflight[callID]
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
return slot
}
}
return nil
}
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
@@ -451,7 +545,9 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
st.toolMu.Lock()
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
st.toolMu.Unlock()
hasCalls := len(ev.Message.ToolCalls) > 0
if st.turnThink.Len() > 0 {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartThinking, Text: st.turnThink.String(), Process: true, DurationMs: durationMs(st.thinkStart, st.thinkEnd)})
@@ -473,6 +569,10 @@ func (st *directRun) onEvent(ev nruntime.Event) {
if body != "" {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body, Process: hasCalls})
st.turnBody = body
if !hasCalls {
// 面向用户的答复段落已出现:快通道成立,双速提醒不再需要。
st.replied = true
}
st.textIdx++
}
}
@@ -484,13 +584,19 @@ func (st *directRun) onEvent(ev nruntime.Event) {
if len(st.queued) == 0 {
return
}
call := st.queued[0]
st.queued = st.queued[1:]
st.inflight = &toolSlot{call: call, start: st.l.now()}
call, ok := st.takeQueued(ev.CallID)
if !ok {
return
}
st.toolMu.Lock()
st.inflight[call.ID] = &toolSlot{call: call, start: st.l.now()}
st.toolMu.Unlock()
_ = st.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
case "tool_execution_end":
slot := st.inflight
st.toolMu.Lock()
slot := st.takeInflight(ev.CallID)
if slot == nil {
st.toolMu.Unlock()
return
}
end := st.l.now()
@@ -502,12 +608,38 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.deliverables = append(st.deliverables, slot.artifact)
}
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
st.toolMu.Unlock()
st.noteFailureOutcome(slot.status)
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
st.inflight = nil
}
}
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
// 材料整理兜底(附件即保证):带附件回合结束仍无后台任务且模型也没自行
// 委派(delegatedBackground 尊重模型判断)→ 系统确定性启动,续跑一轮让
// 模型告知用户。ensureMaterial 幂等 + materialChecked 一次性:不续跑循环。
// 实测动机:MiniMax 对入口提示无视、问一句澄清就收场,0 委派 0 卡片,
// 用户全程无反馈。
if !st.materialChecked && len(st.rc.Attachments()) > 0 && !st.delegatedBackground {
if ens, ok := st.rc.(interface{ EnsureMaterial() string }); ok {
st.materialChecked = true
if note := strings.TrimSpace(ens.EnsureMaterial()); note != "" {
return nruntime.StopDecision{Continue: true, Reason: materialBackstopReason}
}
}
}
// 交付物确认链(I2 混合兜底):用户已在选择卡拍板,但回合结束仍未委派
// 后台任务 → 硬性续跑一轮要求启动(一次性)。
if st.pendingDeliverable != "" && !st.delegatedBackground && !st.deliverableNudged {
st.deliverableNudged = true
return nruntime.StopDecision{Continue: true, Reason: deliverableBackstopReason}
}
// 材料通知回合应急(弱模型不发 confirm_choice):汇报了却没让用户选
// 交付物、也没委派任何后台 → 硬性要求发起选择卡(一次性)。
if st.materialNotice && !st.confirmIssued && !st.delegatedBackground && !st.materialNoticeNudged {
st.materialNoticeNudged = true
return nruntime.StopDecision{Continue: true, Reason: materialNoticeBackstopReason}
}
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
st.demoteLastText()
if st.eff.GetBool("runtime.verify", false) {
@@ -529,23 +661,37 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
return st.continueOrNotice(info, strings.Join(reasons, ";"))
}
// P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料;
// 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的
// 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked {
// P5→E3:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料
// (附件解析文本 + 工具证据)。E3 收紧为两轮:第一轮照常给修正机会
// (改值/删除/加「目标/假设」标注——标注后经 MarkedAsEstimate 机械放行);
// 第二轮明确这是最后一次拦截并给出标注出路;两轮后不再拦截(记入检查行
// 如实呈现),续写上限(MaxVerifyContinuations)仍兜底防死循环。
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations {
pairs := st.collectDeliverableText(latest)
if len(pairs) > 0 {
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
st.factChecked = true
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))}
if notice, cont := st.factCheckGate(tokens); cont {
// 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入
// 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037
// 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。
st.demoteLastText()
st.addVerifyNote("事实核查:" + notice)
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + notice)}
}
}
}
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
if err != nil {
st.addVerifyNote(err.Error())
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
// 验证器没跑成不再是用户可见正文(实测 Notice 会顶掉正式总结)。
// 同源跳过是能力说明,记通过态检查行;真失败记未通过态检查行,
// 过程时间线里都可见,最终答复只留模型自己的收尾。
if strings.Contains(err.Error(), "跳过同源验证") {
st.addCheck(true, verifierUnavailableNotice)
} else {
st.addVerifyNote(err.Error())
}
return nruntime.StopDecision{}
}
if !verdict.Passed {
reason := strings.TrimSpace(verdict.Reason)
@@ -640,14 +786,24 @@ func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nru
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
}
st.addVerifyNote(reason)
return nruntime.StopDecision{Notice: verifyCapNotice}
// 达到续写上限同样收进检查行(未通过态):让用户看见检查确实没过,
// 而不是用一句系统提示替换掉模型的最终答复。
st.addCheck(false, verifyCapNotice)
return nruntime.StopDecision{}
}
func (st *directRun) runTool(name, args string) agenttools.Result {
call := agentllm.ToolCall{Name: name, Arguments: args}
if st.inflight != nil {
call = st.inflight.call
return st.runToolCtx(context.Background(), name, args)
}
// runToolCtx 执行一个工具调用;ctx 携带内核注入的当前调用(并行执行下按
// CallID 定位在途槽位),无注入时回落唯一在途槽位(旧内核/测试直调)。
func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttools.Result {
call, _ := nruntime.ToolCallFromContext(ctx)
if call.ID == "" {
call = agentllm.ToolCall{Name: name, Arguments: args}
}
slot := st.slotFor(call.ID)
var out string
var err error
switch call.Name {
@@ -675,11 +831,18 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if sp, ok := st.rc.(interface {
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
}); ok {
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint)
// 材料传递(R2):模型自派的后台任务同样带上材料指引——子代理
// 看不到父回合内联的附件全文,需指明检索通道。
task := a.Task
if len(st.rc.Attachments()) > 0 {
task += delegateMaterialsNote(st.rc.Attachments())
}
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), task, a.Hint)
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
out = fmt.Sprintf("已在后台启动 %s,完成后会自动通知", id)
st.delegatedBackground = true
out = fmt.Sprintf("已在后台启动 %s(慢通道):收件箱已建进度卡,完成后结果回投该卡并自动汇报;先把快通道的即时反馈写给用户,不要等待", id)
}
break
}
@@ -696,9 +859,62 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
out = orDefault(res.Summary, "子代理完成")
// F1 修复:回传子代理结论全文(capSubagentResult 已做 32k 截断与
// 超长落盘指针),Summary 只是 160 字摘要——只回摘要会让父代理在
// 摘要的摘要上凭空补细节。
out = orDefault(res.Text, orDefault(res.Summary, "子代理完成"))
st.pinSubagentArtifacts(res.Summary + "\n" + res.Text)
}
case toolConfirmChoice:
var a struct {
Question string `json:"question"`
Options []struct {
ID, Label, Description, Impact string
Recommended bool
} `json:"options"`
FreeInput *bool `json:"free_input"`
TimeoutSec int `json:"timeout_sec"`
}
if uerr := json.Unmarshal([]byte(call.Arguments), &a); uerr != nil || strings.TrimSpace(a.Question) == "" || len(a.Options) < 2 {
out = "confirm_choice 参数无效:需要 question 与 2-4 个选项(id/label/description/impact)"
break
}
card := core.HITLCard{Kind: core.HITLKindChoice, Question: strings.TrimSpace(a.Question), AllowFreeInput: a.FreeInput == nil || *a.FreeInput}
for _, o := range a.Options {
choice := core.HITLChoice{ID: strings.TrimSpace(o.ID), Label: strings.TrimSpace(o.Label), Description: o.Description, Impact: o.Impact, Recommended: o.Recommended}
if choice.ID == "" {
choice.ID = choice.Label
}
if strings.TrimSpace(choice.Impact) == "" {
choice.Impact = "按选项说明执行"
}
card.Choices = append(card.Choices, choice)
}
if a.TimeoutSec > 0 {
secs := a.TimeoutSec
card.TimeoutSec = &secs
}
st.confirmIssued = true
resp, herr := st.rc.AskHITL(card)
if herr != nil {
out = "选择等待结束(未获得答复):" + herr.Error() + "。请在回复里列出可选项,请用户直接答复。"
break
}
if id := strings.TrimSpace(resp.ChoiceID); id != "" {
label := id
for _, c := range card.Choices {
if c.ID == id {
label = c.Label
}
}
st.pendingDeliverable = label
out = "用户已选择:" + label + "。立即用 delegate_task 的 background=true 启动对应后台任务(任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点),启动后给用户一句确认反馈即可,不要等待完成。"
} else if free := strings.TrimSpace(resp.FreeText); free != "" {
st.pendingDeliverable = free
out = "用户自由输入:" + free + "。若这是正式交付物要求,立即用 delegate_task 的 background=true 启动对应后台任务(描述结合材料整理结果);若是澄清或追问,先如实答复,再按需推进。"
} else {
out = "用户未给出明确答复。请在回复里列出可选项,请用户直接答复。"
}
case toolReview:
out = st.reviewFindings(call.Arguments)
case toolPlan:
@@ -738,8 +954,16 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
} else {
out = res.Output
}
if res.Artifact != nil && st.inflight != nil {
st.inflight.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
// E4:R4 空命中计数 + 证据账本登记(成功且非空的证据类工具输出
// 尾注【src_N|工具】来源标记——数字溯源从此有可指地址)。
st.noteEvidenceCall(call.Name, res.Status, out)
if res.Status == "ok" && evidenceTools[call.Name] && strings.TrimSpace(out) != "" {
if tag, ok := st.tagSource(call.Name, out); ok {
out += "\n【" + tag + "】"
}
}
if res.Artifact != nil && slot != nil {
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
if st.cardflow && res.Truncated && !st.explicitCard {
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
if ferr == nil {
@@ -747,8 +971,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
}
}
}
if st.inflight != nil {
st.inflight.truncated = res.Truncated
if slot != nil {
slot.truncated = res.Truncated
}
}
@@ -758,10 +982,10 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
summary = "error: " + err.Error()
out = summary
}
if st.inflight != nil {
st.inflight.summary = summary
st.inflight.status = status
st.inflight.verifyText = tailRunes(out, 600)
if slot != nil {
slot.summary = summary
slot.status = status
slot.verifyText = tailRunes(out, 600)
}
if r := []rune(out); len(r) > 16000 {
out = string(r[:16000]) + "…(超长截断)"
@@ -952,3 +1176,65 @@ func errString(err error) string {
}
return err.Error()
}
// ---- 并行工具执行的辅助(E1/F5)----
// serialTools 顺序敏感或带会话级副作用的工具:作为内核 SerialTools 传入——
// 并行执行下这些调用按模型声明顺序逐个执行(互斥且保序;只互斥不保序会让
// 流式卡 card_begin→append→commit 乱序,实测踩过)。卡片流四步有先后依赖,
// 计划/待办是整表更新,工作流与小队编排派生 run,执行原语与文件编辑共享会话
// 工作区。读检索类与 delegate_task/review_findings 并行。
var serialTools = map[string]bool{
toolPlan: true, toolTodos: true,
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true,
"run_shell": true, "run_python": true, "register_artifact": true,
"edit": true, "multi_edit": true,
}
// toolExecMode 同回合多工具调用的执行模式:runtime.parallelTools(缺省开)
// 为 parallel(顺序敏感工具经 Config.SerialTools 由内核保序),关闭时空串
// 回落内核缺省 sequential。
func toolExecMode(eff core.EffectiveSettings) string {
if eff.GetBool("runtime.parallelTools", true) {
return nruntime.ExecParallel
}
return ""
}
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
if callID != "" {
for i, c := range st.queued {
if c.ID == callID {
st.queued = append(st.queued[:i], st.queued[i+1:]...)
return c, true
}
}
return agentllm.ToolCall{}, false
}
if len(st.queued) == 0 {
return agentllm.ToolCall{}, false
}
c := st.queued[0]
st.queued = st.queued[1:]
return c, true
}
// takeInflight 按事件 CallID 取出并删除在途槽位;CallID 为空且恰有一个在途
// 槽位时取之(兼容旧内核)。调用方须持有 toolMu。
func (st *directRun) takeInflight(callID string) *toolSlot {
if callID != "" {
slot := st.inflight[callID]
delete(st.inflight, callID)
return slot
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
clear(st.inflight)
return slot
}
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package runtime
import (
"strings"
"testing"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// ---- F1:delegate_task 回传子代理全文,不再只有 160 字摘要 ----
func TestDelegateToolReturnsFullText(t *testing.T) {
full := strings.Repeat("子代理结论细节。", 60) // 480 字,远超 160
f := &fakeRunCtx{spawnAgentFn: func(agentID, name, task, hint string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{Status: core.RunCompleted, Text: full, Summary: "完成(160字内摘要)"}, nil
}}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("delegate_task", `{"name":"调研","task":"查证X"}`)
if res.IsError {
t.Fatalf("delegate failed: %s", res.Content)
}
if !strings.Contains(res.Content, "子代理结论细节。子代理结论细节。子代理结论细节。") {
t.Fatalf("delegate result must carry the full child text, got: %.120s…", res.Content)
}
if strings.Contains(res.Content, "160字内摘要") {
t.Fatalf("summary must be fallback only, got: %.120s…", res.Content)
}
}
// ---- F5:并行执行下工具事件按 CallID 配对 ----
func TestToolEventsPairByCallID(t *testing.T) {
f := &fakeRunCtx{}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
st.queued = []agentllm.ToolCall{
{ID: "c1", Name: "knowledge_search", Arguments: `{}`},
{ID: "c2", Name: "web_search", Arguments: `{}`},
}
// 乱序进出:c1 先开始,c2 先结束(并行下的真实时序)。
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "knowledge_search", CallID: "c1"})
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "web_search", CallID: "c2"})
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "web_search", CallID: "c2"})
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "knowledge_search", CallID: "c1"})
if len(st.inflight) != 0 {
t.Fatalf("inflight must be empty after both ends: %v", st.inflight)
}
if len(st.finalParts) != 4 {
t.Fatalf("finalParts = %d (want 4: call+result ×2)", len(st.finalParts))
}
// 结束顺序决定部件顺序:先 c2 后 c1。
if got := st.finalParts[0].ToolCall.ID; got != "c2" {
t.Fatalf("first tool part = %s (want c2)", got)
}
if got := st.finalParts[1].ToolResult.CallID; got != "c2" {
t.Fatalf("first result part = %s (want c2)", got)
}
if got := st.finalParts[2].ToolCall.ID; got != "c1" {
t.Fatalf("second tool part = %s (want c1)", got)
}
if got := st.finalParts[3].ToolResult.CallID; got != "c1" {
t.Fatalf("second result part = %s (want c1)", got)
}
}
// ---- F5:执行模式与串行集合 ----
func TestToolExecModeAndSerialSet(t *testing.T) {
if got := toolExecMode(core.EffectiveSettings{}); got != nruntime.ExecParallel {
t.Fatalf("default must be parallel, got %q", got)
}
off := core.EffectiveSettings{"runtime": map[string]any{"parallelTools": false}}
if got := toolExecMode(off); got != "" {
t.Fatalf("parallelTools=false must fall back to sequential, got %q", got)
}
// 顺序敏感工具必须串行;可并行的读检索与委派不得误入。
for _, name := range []string{"update_plan", "update_todos", "submit_card", "card_begin", "card_append", "card_commit", "run_workflow", "squad_orchestrate", "run_shell", "run_python", "register_artifact"} {
if !serialTools[name] {
t.Fatalf("serialTools missing %s", name)
}
}
for _, name := range []string{"delegate_task", "review_findings", "knowledge_search", "web_search", "fs_read_file", "write_artifact"} {
if serialTools[name] {
t.Fatalf("serialTools must not contain %s", name)
}
}
}
// ---- F2:MiniMax 前缀自动置位 reasoning_split ----
func TestOpenLLMClientReasoningSplitAuto(t *testing.T) {
if c := openLLMClient("http://x", "k", "MiniMax-M2.7-highspeed"); !c.ReasoningSplit {
t.Fatal("MiniMax model must set ReasoningSplit")
}
if c := openLLMClient("http://x", "k", "MiniMax-M3"); !c.ReasoningSplit {
t.Fatal("MiniMax M3 must set ReasoningSplit")
}
if c := openLLMClient("http://x", "k", "claude-sonnet-5"); c.ReasoningSplit {
t.Fatal("non-MiniMax model must not set ReasoningSplit")
}
}
+29 -3
View File
@@ -42,9 +42,14 @@ type fakeRunCtx struct {
bgID string
bgErr error
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试)
execOutput string // 非空时覆盖 ExecuteTool 输出(空命中/账本测试)
}
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
@@ -55,7 +60,11 @@ func (f *fakeRunCtx) Settings() core.EffectiveSettings {
if f.cardflow {
eff["ui"] = map[string]any{"cardflow": true}
}
eff["runtime"] = map[string]any{"verify": f.verify, "composeFinal": f.compose}
runtimeMap := map[string]any{"verify": f.verify, "composeFinal": f.compose}
for k, v := range f.runtimeExtra {
runtimeMap[k] = v
}
eff["runtime"] = runtimeMap
return eff
}
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
@@ -78,6 +87,15 @@ func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
func (f *fakeRunCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
}
// SpawnAgent 供 delegate_task 直连分支(E1/F1 测试注入);未设置时与
// SpawnSubagent 一致报错。
func (f *fakeRunCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
if f.spawnAgentFn != nil {
return f.spawnAgentFn(agentID, name, task, hint)
}
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
}
func (f *fakeRunCtx) UpdatePlan(p core.PlanSnapshot) error { return nil }
func (f *fakeRunCtx) UpdateTodos(l core.TodoList) error { return nil }
func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
@@ -87,6 +105,12 @@ func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
f.toolRuns++
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
if f.execStatus != "" {
res.Status = f.execStatus
}
if f.execOutput != "" {
res.Output = f.execOutput
}
if f.execArtifact != nil {
res.Artifact = f.execArtifact
}
@@ -136,7 +160,7 @@ func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
return fakeMemStore{entries: f.memEntries}
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments }
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" }
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
@@ -1093,3 +1117,5 @@ func transcriptOf(msgs []agentllm.Message) string {
}
return b.String()
}
func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil }
+5 -3
View File
@@ -214,16 +214,18 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
t.Fatalf("main calls = %d, want 1", mainCalls)
}
texts := textParts(rc.assistant)
var kept, notice bool
var kept bool
notice := false
for _, p := range texts {
if p.Text == "答案是 42。" && !p.Process {
kept = true
}
if !p.Process && strings.Contains(p.Text, "验证器未独立配置") {
if !p.Process && strings.Contains(p.Text, "验证器") {
notice = true
}
}
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
// 2026-10-10 契约更新:验证器不可用不再作为正文 Notice,落成过程检查行。
if !kept || notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
t.Fatalf("parts = %+v", texts)
}
})
+222
View File
@@ -0,0 +1,222 @@
package runtime
import (
"context"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// matCtx 在 fakeRunCtx 之上实现材料整理的两个可选接口(附件即保证),
// 记录 EnsureMaterial 调用次数以断言 stop 兜底的幂等。
type matCtx struct {
*fakeRunCtx
note string
ensureN int
lastNote string
// confirm_choice 断言(I2)
hitls []core.HITLCard
hitlFree string
hitlErr error
}
func (m *matCtx) MaterialNote() string { return m.note }
func (m *matCtx) EnsureMaterial() string {
m.ensureN++
if m.ensureN > 1 {
return ""
}
return m.lastNote
}
// AskHITL 覆盖:预置选择默认取第一个选项;hitlFree 模拟自由输入。
func (m *matCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
m.hitls = append(m.hitls, card)
if m.hitlErr != nil {
return core.HITLResponse{}, m.hitlErr
}
if m.hitlFree != "" {
return core.HITLResponse{FreeText: m.hitlFree}, nil
}
if len(card.Choices) == 0 {
return core.HITLResponse{}, nil
}
return core.HITLResponse{ChoiceID: card.Choices[0].ID}, nil
}
func matAttachments() []core.ParsedDocument {
return []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定 11 月上线"}}},
{Meta: core.DocMeta{Filename: "意见.pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "预算存疑"}}},
{Meta: core.DocMeta{Filename: "清单.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "12 项需求"}}},
}
}
func runMaterial(t *testing.T, turns [][]agentllm.Event, rc *matCtx) *dualSpeedRecorder {
t.Helper()
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
return rec
}
func requestsContaining(rec *dualSpeedRecorder, needle string) int {
n := 0
for _, req := range rec.request {
if strings.Contains(req, needle) {
n++
}
}
return n
}
// 预启动说明替换建议式提示:第 0 回合注入事实说明而非「可能属于重任务」。
func TestTurn0MaterialNoteReplacesAdvisory(t *testing.T) {
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, note: "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建)。"}
rc.attachments = matAttachments()
rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("收到,先给每份材料一句话概览。")}, rc)
if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 {
t.Fatal("material note not injected at turn 0")
}
if requestsContaining(rec, "可能属于重任务") != 0 {
t.Fatal("advisory text should be replaced when material note present")
}
}
// 未预启动(文字+小材料):保留建议式入口提示。
func TestTurn0AdvisoryWhenNoNote(t *testing.T) {
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}}
rc.attachments = matAttachments()
rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("概览如下。")}, rc)
if requestsContaining(rec, "可能属于重任务") == 0 {
t.Fatal("advisory hint missing when no material note")
}
}
// stop 兜底:模型问一句澄清就收场(用户实测现象)→ 系统续跑一轮要求告知;
// ensureMaterial 幂等保证只续跑一次。
func TestStopMaterialBackstopContinuesOnce(t *testing.T) {
rc := &matCtx{
fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()},
lastNote: "系统已自动启动材料整理后台任务",
}
rc.attachments = matAttachments()
turns := [][]agentllm.Event{
textDeltaTurn("请问你需要哪种产出?"),
textDeltaTurn("材料正在后台整理,完成后自动汇报;先给概览。"),
}
rec := runMaterial(t, turns, rc)
if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 {
t.Fatal("backstop reason not injected as continuation")
}
if rc.ensureN != 1 {
t.Fatalf("EnsureMaterial calls = %d, want exactly 1", rc.ensureN)
}
}
// 模型已自行委派后台(delegatedBackground)→ 尊重模型判断,不触发兜底。
func TestStopMaterialBackstopSkippedWhenDelegated(t *testing.T) {
rc := &matCtx{
fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_1"},
}
rc.attachments = matAttachments()
delegate := toolCall("delegate_task", `{"name":"深度解析","task":"解析材料","background":true}`)
turns := [][]agentllm.Event{
textDeltaTurn("", delegate),
textDeltaTurn("已在后台解析,先给即时反馈。"),
}
rec := runMaterial(t, turns, rc)
if rc.ensureN != 0 {
t.Fatalf("EnsureMaterial calls = %d, want 0 (model delegated)", rc.ensureN)
}
if requestsContaining(rec, "自动启动材料整理后台任务") != 0 {
t.Fatal("backstop should not fire when model delegated")
}
}
func hitlTurn(call agentllm.ToolCall) []agentllm.Event { return textDeltaTurn("", call) }
// 完整确认链:材料通知回合 → 汇报但没发选择卡(应急兜底)→ 发起 confirm_choice
// → 用户选择 → 模型委派后台 → 通过(无进一步拦截)。
func TestMaterialNoticeChoiceChain(t *testing.T) {
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_d"}}
rc.history = []core.Message{{Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: "<system-reminder>\n后台子代理「材料整理」已结束。\n状态:completed\n整理完成\n</system-reminder>"}}}}
turns := [][]agentllm.Event{
textDeltaTurn("整理完成:三份材料要点如下……"), // 未发选择卡 → 应急兜底
hitlTurn(toolCall("confirm_choice", `{"question":"接下来生成什么?","options":[{"id":"a","label":"需求文档","description":"会议含明确需求项","impact":"产出需求文档"},{"id":"b","label":"会议摘要","description":"快速回顾","impact":"产出摘要"}]}`)), // AskHITL → 用户选第一个
textDeltaTurn("好的,启动需求文档任务……", toolCall("delegate_task", `{"name":"生成需求文档","task":"基于整理结果生成需求文档","background":true}`)),
textDeltaTurn("已在后台启动需求文档,完成后自动汇报。"),
}
rec := runMaterial(t, turns, rc)
if requestsContaining(rec, "还没有让用户选择正式交付物") == 0 {
t.Fatal("notice backstop (confirm_choice nudge) missing")
}
if len(rc.hitls) != 1 || rc.hitls[0].Kind != core.HITLKindChoice {
t.Fatalf("hitl cards = %d", len(rc.hitls))
}
if requestsContaining(rec, "用户已选择:需求文档") == 0 {
t.Fatal("choice answer not surfaced to model")
}
if requestsContaining(rec, "还没有启动") != 0 {
t.Fatal("deliverable backstop should not fire after model delegated")
}
if rc.ensureN != 0 {
t.Fatal("material backstop must not run in notice turns (no attachments)")
}
}
// 用户已选定但模型未委派 → 硬性续跑一轮要求启动;第二轮委派后放行。
func TestDeliverableBackstopForcesDelegation(t *testing.T) {
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_e"}}
turns := [][]agentllm.Event{
hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"会议摘要","description":"快","impact":"摘要"},{"id":"b","label":"评审报告","description":"全","impact":"报告"}]}`)),
textDeltaTurn("好的,马上生成会议摘要。"), // 未委派 → 兜底
textDeltaTurn("这就启动。", toolCall("delegate_task", `{"name":"生成会议摘要","task":"生成摘要","background":true}`)),
textDeltaTurn("已在后台生成会议摘要,稍后回投。"),
}
rec := runMaterial(t, turns, rc)
// 只触发一次由脚本回合数锁死:再续跑一次会多一次模型调用,脚本耗尽报错。
if requestsContaining(rec, "还没有启动") == 0 {
t.Fatal("deliverable backstop missing")
}
if requestsContaining(rec, "用户已选择:会议摘要") == 0 {
t.Fatal("choice answer not surfaced to model")
}
}
// 自由输入也应回到同一回合并进入待交付状态。
func TestConfirmChoiceFreeTextSetsPending(t *testing.T) {
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, hitlFree: "做成对比表格"}
turns := [][]agentllm.Event{
hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)),
textDeltaTurn("收到,将输出对比表格。", toolCall("delegate_task", `{"name":"对比表格","task":"输出对比表格","background":true}`)),
textDeltaTurn("已在后台生成对比表格。"),
}
rec := runMaterial(t, turns, rc)
if requestsContaining(rec, "用户自由输入:做成对比表格") == 0 {
t.Fatal("free input not surfaced to model")
}
if requestsContaining(rec, "还没有启动") != 0 {
t.Fatal("no backstop expected when model delegated after free input")
}
}
// 参数无效(选项不足)不建卡,直接给模型纠错说明。
func TestConfirmChoiceRejectsInvalidArgs(t *testing.T) {
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}}
turns := [][]agentllm.Event{
hitlTurn(toolCall("confirm_choice", `{"question":"选什么?"}`)),
textDeltaTurn("改用两个选项重试。", toolCall("confirm_choice", `{"question":"选什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)),
textDeltaTurn("这就启动摘要。", toolCall("delegate_task", `{"name":"生成摘要","task":"生成摘要","background":true}`)),
textDeltaTurn("已在后台生成摘要。"),
}
runMaterial(t, turns, rc)
if len(rc.hitls) != 1 {
t.Fatalf("invalid args should not create card; hitls = %d", len(rc.hitls))
}
}
+14
View File
@@ -226,6 +226,20 @@ func (m *MockRunner) Execute(rc RunCtx) error {
}
parts = append(parts, res.Parts...)
case strings.Contains(user, "#background"):
// 慢通道演示(任务快慢双速,SR-B10):快通道先反馈,后台子代理
// 进度进收件箱卡片,完成后结果回投该卡并发起汇报回合。
addText("✅ 快通道:材料已接收,先给结论——这是 mock 演示任务。深度解析已放慢通道后台执行:进度看右栏收件箱卡片,完成后自动回投结果并汇报。")
if sp, ok := rc.(interface {
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
}); ok {
if _, err := sp.SpawnBackground("agent.sub.general", "深度解析员", "对上传材料逐页解析并生成要点摘要(mock 后台演示)", "演示任务"); err != nil {
addText("后台启动失败:" + err.Error())
}
} else {
addText("当前运行上下文不支持后台派发(演示不可用)")
}
case strings.Contains(user, "#error"):
return fmt.Errorf("mock: 演示失败路径(#error 指令)")
+2
View File
@@ -50,6 +50,8 @@ type RunCtx interface {
Card(id string) *core.ActivityCard
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;无则 false)。
Artifact(id string) (core.Artifact, []byte, bool)
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌)。
Surfaces() []core.SurfaceSnapshot
// SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。
SetContextObservation(o core.ContextObservation)
}
+1 -1
View File
@@ -139,7 +139,7 @@ func newModelRouter(rc RunCtx) modelRouter {
if route.Model == "" || route.Base == "" {
return modelRouter{}
}
client := openLLMClient(route.Base, route.Key)
client := openLLMClient(route.Base, route.Key, route.Model)
return modelRouter{stream: client.Stream, model: route.Model}
}
+2
View File
@@ -461,3 +461,5 @@ func containsStr(list []string, want string) bool {
}
return false
}
func (*entryFake) Surfaces() []core.SurfaceSnapshot { return nil }
+35 -1
View File
@@ -19,6 +19,11 @@ const subagentFallback = "你是被派生的子代理。独立完成给定子任
const defaultSubagentID = "agent.sub.general"
// materialSubagentID 材料整理子代理(附件即保证):系统确定性派发,不依赖
// 提示词库配置即可工作——spec 缺省在代码内(对话同档模型 + 检索;fs 工具是
// 预留能力默认关,材料通道 = 任务正文内联 + knowledge_search)。
const materialSubagentID = "agent.sub.material"
func fallbackSubagentSpec() prompts.SubagentSpec {
return prompts.SubagentSpec{
Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"},
@@ -26,6 +31,13 @@ func fallbackSubagentSpec() prompts.SubagentSpec {
}
}
func materialSubagentSpec() prompts.SubagentSpec {
return prompts.SubagentSpec{
Tools: []string{"knowledge_search", "time_now"},
Model: "chat",
}
}
func (l *SubLLMRunner) Name() string { return "llm-subagent" }
func (l *SubLLMRunner) Execute(rc RunCtx) error {
@@ -36,11 +48,18 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
}
}
spec := fallbackSubagentSpec()
if id == materialSubagentID {
spec = materialSubagentSpec()
}
if l.Prompts != nil {
if got, ok := l.Prompts.SubagentSpec(rc.Profile().Owner, id); ok {
spec = got
}
}
// 系统派发的模型档覆盖(材料整理 = 对话同档):优先于 spec 默认。
if src, ok := rc.(interface{ Submodel() string }); ok && src.Submodel() == "chat" {
spec.Model = "chat"
}
maxTurns := spec.MaxTurns
if maxTurns <= 0 {
maxTurns = math.MaxInt32
@@ -54,7 +73,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
if route.Model == "" || route.Base == "" {
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
}
client := openLLMClient(route.Base, route.Key)
client := openLLMClient(route.Base, route.Key, route.Model)
stream = client.Stream
}
if spec.Model == "chat" {
@@ -253,6 +272,21 @@ func (st *subRun) runTool(name, args string) agenttools.Result {
return agenttools.Result{Content: summary}
}
// delegateMaterialsNote 模型自派后台任务的材料指引(R2):子代理看不到父回合
// 内联的附件全文,指明 knowledge_search 检索通道(上传时解析文本已进索引)。
func delegateMaterialsNote(docs []core.ParsedDocument) string {
names := make([]string, 0, len(docs))
for _, d := range docs {
if n := strings.TrimSpace(d.Meta.Filename); n != "" {
names = append(names, n)
}
}
if len(names) == 0 {
return ""
}
return "\n\n[系统补充] 本会话材料文件:" + strings.Join(names, "、") + "。解析全文可用 knowledge_search 以文件名为关键词检索获取。"
}
func subagentProfileID(agent string) string {
switch agent {
case "explore", "research", "plan", "verify", "general", "writer":
+72 -1
View File
@@ -2,6 +2,7 @@ package runtime
import (
"fmt"
"log"
"strings"
"time"
@@ -25,6 +26,22 @@ func systemCheckReason(body string) string {
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
}
// materialBackstopReason 材料整理兜底(附件即保证)的续跑指令:不走
// systemCheckReason 的「核实修复」前缀——这里没有待修的检查,只有事实告知
// 与职责提醒;ensureMaterial 幂等保证只续跑一轮。
const materialBackstopReason = "<system-reminder>\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n</system-reminder>"
// materialNoticeMarker 材料整理完成通知的稳定标记:runmgr 的通知正文自然
// 含此串;runtime 依此识别「本回合负有发起交付物选择卡的职责」。
const materialNoticeMarker = "「材料整理」已结束"
// deliverableBackstopReason 用户已选定交付物但后台任务未启动的硬性续跑指令。
const deliverableBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。用户已经在选择卡上做出决定,但对应的后台任务还没有启动。立即调用 delegate_task(background=true)启动它:任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点;启动后用一句话向用户确认即可,不要等待完成,也不要再次询问用户。\n</system-reminder>"
// materialNoticeBackstopReason 材料整理通知回合未发起选择卡的应急指令
// (弱模型不发 confirm_choice 时兜底,一次性)。
const materialNoticeBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。材料整理已完成并汇报,但还没有让用户选择正式交付物。立即调用 confirm_choice 工具发起选择卡:问题「基于整理结果,接下来生成什么?」;选项从整理结果的「建议后续处理」取 3-4 项(description 写一句材料依据,最贴合的一项 recommended=true),允许自由输入;取不出建议时用默认选项:材料摘要、评审报告、需求文档、架构方案。发起后简短收尾即可。\n</system-reminder>"
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
stream := l.VerifyStream
@@ -41,7 +58,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
if route.Model == "" || route.Base == "" {
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
}
client := openLLMClient(route.Base, route.Key)
client := openLLMClient(route.Base, route.Key, route.Model)
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
stream = agentllm.WithFallback(
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
@@ -149,6 +166,18 @@ func (st *directRun) reminders(turn int) []string {
st.closedToolTurn = false
st.checklistTouched = false
}
// 任务快慢双速(SR-B10 实路径触发):run 已运行超过阈值、用户还没看到任何
// 答复、也没把深度工作放后台时,一次性提醒转双速——先把判断/计划写给用户,
// 重活 delegate 后台。已经回复或已委派则永不打扰;接近完成的任务可按文案
// 自行忽略。这是内核每回合的 Reminders 注入点(agent-native 循环原生钩子)。
if note := st.dualSpeedNudge(); note != "" {
return []string{note}
}
// E4(R4):harness 检测到的换路条件优先于计划提醒——原地打转时
// 计划提醒没有意义。
if notes := st.escalationReminders(); len(notes) > 0 {
return notes
}
if st.toolTurnsSince < gates.ReminderToolGap {
return nil
}
@@ -175,6 +204,48 @@ func (st *directRun) reminders(turn int) []string {
return got
}
// dualSpeedNudgeSeconds:双速提醒的运行时长阈值(秒)。0 = 关闭。
const dualSpeedNudgeDefaultSeconds = 75
// 双速提醒升级:首次被无视后每 45 秒重复、最多 3 次,第二次起改硬性措辞。
// 实测 MiniMax 对单次 system-reminder 无动于衷(opus 一次即响应)——重复+升级是
// 不改内核契约的前提下最强的请求侧手段;仍无效的模型记录到任务卡,后续考虑
// 系统级强制拆分(cancel + 后台续跑)方案。
const (
dualSpeedRepeatEvery = 45 * time.Second
dualSpeedMaxNudges = 3
)
// dualSpeedNudge 返回双速提醒文案;不满足条件或已达重复上限返回空串。
// 条件用注入时钟(l.now)计量,测试可控。
func (st *directRun) dualSpeedNudge() string {
after := int(st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds))
if after <= 0 || st.replied || st.delegatedBackground {
return ""
}
if st.started.IsZero() {
return ""
}
elapsed := st.l.now().Sub(st.started)
if elapsed < time.Duration(after)*time.Second {
return ""
}
due := 1 + int((elapsed-time.Duration(after)*time.Second)/dualSpeedRepeatEvery)
if due > dualSpeedMaxNudges {
due = dualSpeedMaxNudges
}
if st.nudgedDualSpeedCount >= due {
return ""
}
st.nudgedDualSpeedCount = due
secs := int(elapsed.Round(time.Second).Seconds())
log.Printf("[runtime] dual-speed nudge #%d injected at %ds (replied=%v delegated=%v)", due, secs, st.replied, st.delegatedBackground)
if due == 1 {
return fmt.Sprintf("本次任务已连续运行约 %d 秒,用户还没有收到任何答复(任务快慢双速,SR-B10)。现在二选一:若剩余工作量大(预计还要一分钟以上),先把你的判断、概要或执行计划作为即时反馈回复给用户,同时用 delegate_task 的 background=true 把深度处理放到后台执行(收件箱有进度卡,完成后系统自动回投结果并汇报);若已接近完成,把这句话忽略,继续做完后在最终答复里给出完整结论。不要默默长时间工作。", secs)
}
return fmt.Sprintf("用户已经等待约 %d 秒(第 %d 次提醒,硬性要求):立即停止后续工具调用,把当前进展、结论或计划作为回复发给用户;剩余的深度处理用 delegate_task 的 background=true 放到后台执行,完成后系统会自动回投结果。不要再让用户在没有反馈的情况下等待。", secs, due)
}
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
func (st *directRun) unfinishedItems() (todos, plans []string) {
for _, item := range st.todos.Items {
+1 -1
View File
@@ -79,7 +79,7 @@ func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
if route.Model == "" || route.Base == "" {
return nil
}
client := openLLMClient(route.Base, route.Key)
client := openLLMClient(route.Base, route.Key, route.Model)
model := route.Model
return func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
return client.Stream(ctx, model, msgs, tools, opts)
@@ -291,3 +291,5 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)")
}
}
func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }
+7 -2
View File
@@ -45,8 +45,13 @@ func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) {
}
texts := textParts(rc.assistant)
last := texts[len(texts)-1]
if last.Process || !strings.Contains(last.Text, "本轮回复未通过终轮检查") {
t.Fatalf("final notice = %+v, want non-process verify cap notice", last)
// 2026-10-10 契约更新:封顶提示不再作为正文 Notice;最后一次回复留在正式区,
// 封顶事实以未通过态检查行收进过程区。
if last.Process || last.Text != "答案 v4。" {
t.Fatalf("final text = %+v, want the model's own last reply", last)
}
if !hasProcessNote(rc.assistant, "本轮回复未通过终轮检查") {
t.Fatalf("cap check note missing: %+v", rc.assistant)
}
// 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。
notes := 0
+25
View File
@@ -279,3 +279,28 @@ func RandomPassword() string {
rand.Read(raw)
return hex.EncodeToString(raw)
}
// ---- 管理员:恢复隔离的核对与解除(协作存储 U3)----
// listRecoveryHolds 列出未解除的 recovery hold(人工核对的运维入口)。
func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
holds, err := s.st.ListOpenRecoveryHolds()
writeJSONOr(w, map[string]any{"holds": holds}, err)
}
// releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次
// 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。
func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
runID := r.PathValue("runId")
if _, err := s.st.GetRecoveryHold(runID); err != nil {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.ReleaseRecoveryHold(runID))
}
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"testing"
core "agent-system-core"
)
// 协作存储 U3 切片 2(KTD13):动作授权矩阵。未配置的动作默认拒绝;
// 旧只读 EXT-8 适配器只保留 read 能力;action-aware 适配器注册后权威。
func newMatrixServer(t *testing.T) *Server {
t.Helper()
s := &Server{}
t.Cleanup(func() {})
return s
}
// 无任何适配器:全部动作拒绝(默认拒绝;与 EXT-8 未注册时读取 NotFound 同基调)。
func TestActionMatrixDefaultDeny(t *testing.T) {
s := newMatrixServer(t)
for _, a := range []ProjectAction{ProjectRead, ProjectEdit, ProjectApprove, ProjectExecute, ProjectAdmin} {
if s.authorizeProjectAction("alice", "p1", a) {
t.Fatalf("未注册适配器时 %s 应默认拒绝", a)
}
}
}
// 旧只读适配器:read 放行、其余动作不得自动获得写入能力(KTD13 兼容语义)。
func TestActionMatrixLegacyAdapterReadOnly(t *testing.T) {
s := newMatrixServer(t)
s.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "alice", nil
})
if !s.authorizeProjectAction("alice", "p1", ProjectRead) {
t.Fatal("alice read 应放行(旧适配器)")
}
for _, a := range []ProjectAction{ProjectEdit, ProjectApprove, ProjectExecute, ProjectAdmin} {
if s.authorizeProjectAction("alice", "p1", a) {
t.Fatalf("旧只读适配器不得自动获得 %s 能力", a)
}
}
// 他人(动作越权):read 同样拒绝。
if s.authorizeProjectAction("bob", "p1", ProjectRead) {
t.Fatal("bob 越权 read 应拒绝")
}
}
// action-aware 适配器:注册后权威(read 与显式配置的动作放行,未配置动作
// 默认拒绝;出错 fail-closed)。
func TestActionMatrixActionAwareAuthoritative(t *testing.T) {
s := newMatrixServer(t)
// 同时注册旧适配器:action-aware 存在时不得回落旧适配器(权威唯一)。
s.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return true, nil
})
s.SetProjectActionAuthorizer(func(owner string, projectID core.ID, action ProjectAction) (bool, error) {
switch action {
case ProjectRead, ProjectEdit:
return owner == "alice", nil
default:
return false, nil // approve/execute/admin 未授予
}
})
if !s.authorizeProjectAction("alice", "p1", ProjectRead) || !s.authorizeProjectAction("alice", "p1", ProjectEdit) {
t.Fatal("alice read/edit 应放行")
}
for _, a := range []ProjectAction{ProjectApprove, ProjectExecute, ProjectAdmin} {
if s.authorizeProjectAction("alice", "p1", a) {
t.Fatalf("未授予的 %s 应默认拒绝", a)
}
}
if s.authorizeProjectAction("bob", "p1", ProjectEdit) {
t.Fatal("bob 越权 edit 应拒绝")
}
// 适配器出错:fail-closed(错误不区分,避免泄露存在性)。
s.SetProjectActionAuthorizer(func(owner string, projectID core.ID, action ProjectAction) (bool, error) {
return false, errBoom
})
if s.authorizeProjectAction("alice", "p1", ProjectRead) {
t.Fatal("适配器出错应 fail-closed")
}
}
var errBoom = &boomErr{}
type boomErr struct{}
func (*boomErr) Error() string { return "boom" }
+91
View File
@@ -0,0 +1,91 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
)
// WP3-I3:GET /api/admin-zone/sections 展平编译插件的分区声明并按
// Order→ID 稳定排序;无声明时空数组(非 null)。
func TestListAdminZoneSections(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Auth = NewAuthenticator(st, true)
srv.CompiledPlugins = []CompiledPlugin{
{ID: "rdm", AdminZones: []AdminZoneSection{
{ID: "rdm.audit", Title: "项目追溯与审计", Icon: "search", Order: 20},
}},
{ID: "other", AdminZones: []AdminZoneSection{
{ID: "other.z", Title: "Z", Order: 10},
{ID: "other.a", Title: "A", Order: 10},
}},
}
h := srv.Handler()
key, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
get := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 未认证拒绝。
if rec := httptest.NewRecorder(); func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/admin-zone/sections", nil)
h.ServeHTTP(rec, req)
return rec
}().Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated request should 401")
}
rec := get("/api/admin-zone/sections")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var got []AdminZoneSection
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
wantIDs := []string{"other.a", "other.z", "rdm.audit"} // Order 同为 10 的按 ID,再 Order 20
if len(got) != 3 {
t.Fatalf("sections = %+v", got)
}
for i, w := range wantIDs {
if got[i].ID != w {
t.Fatalf("sections[%d] = %s, want %s (full: %+v)", i, got[i].ID, w, got)
}
}
// 无声明时空数组非 null。
srv.CompiledPlugins = nil
if rec := get("/api/admin-zone/sections"); rec.Body.String() == "null" {
t.Fatalf("empty listing must be [] not null: %s", rec.Body.String())
}
}
+42 -9
View File
@@ -11,6 +11,15 @@ import (
// ownerCtxKey 携带租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
type ownerCtxKey struct{}
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
type authViaCtxKey struct{}
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
const (
AuthViaKey = "key"
AuthViaSession = "session"
)
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
@@ -40,29 +49,41 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
}
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
// 行为不变(两者都无效才 401)。
key := bearerKey(r)
if key == "" {
key = r.URL.Query().Get("key")
}
if key != "" {
owner, ok := a.st.AuthOwner(key)
if !ok {
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
if owner, ok := a.st.AuthOwner(key); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
return
}
next.ServeHTTP(w, r.WithContext(withOwner(r, owner)))
if owner, ok := cookieOwner(r, a.st); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
return
}
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
return
}
if c, err := r.Cookie(SessionCookie); err == nil {
if owner, ok := a.st.SessionOwner(c.Value); ok {
next.ServeHTTP(w, r.WithContext(withOwner(r, owner)))
return
}
if owner, ok := cookieOwner(r, a.st); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
return
}
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
})
}
// cookieOwner 会话 cookie 有效时返回其 owner。
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
if tok := sessionToken(r); tok != "" {
return st.SessionOwner(tok)
}
return "", false
}
// sessionToken 从请求取会话明文(无会话返回空)。
func sessionToken(r *http.Request) string {
if c, err := r.Cookie(SessionCookie); err == nil {
@@ -75,6 +96,18 @@ func withOwner(r *http.Request, owner string) context.Context {
return context.WithValue(r.Context(), ownerCtxKey{}, owner)
}
func withAuthVia(ctx context.Context, via string) context.Context {
return context.WithValue(ctx, authViaCtxKey{}, via)
}
// AuthVia 返回当前请求的凭证通道(未启用认证/豁免路径为空串)。
func AuthVia(r *http.Request) string {
if v, ok := r.Context().Value(authViaCtxKey{}).(string); ok {
return v
}
return ""
}
func bearerKey(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
+64
View File
@@ -429,3 +429,67 @@ func TestAdminAccountFlow(t *testing.T) {
t.Fatalf("session must die after logout: %d", rec.Code)
}
}
// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决;
// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。
func TestStaleKeySessionFallback(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("init-pass-01")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("carol", "卡罗", hash, false); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"carol","password":"init-pass-01"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
}
tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
req.Header.Set("Authorization", "Bearer ask_stale_key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String())
}
// 失效 key 且无会话:仍 401(key 专属文案)。
req = httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer ask_stale_key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid or revoked API key") {
t.Fatalf("stale key alone must 401 with key error: %d %s", rec.Code, rec.Body.String())
}
// 有效 key:authVia=key。
key, err := st.CreateAPIKey("carol", "cli")
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"key"`) {
t.Fatalf("me via key: %d %s", rec.Code, rec.Body.String())
}
// 纯会话:authVia=session。
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
}
}
+124
View File
@@ -0,0 +1,124 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
"agent-system-backend/pkg/extension"
)
type compiledListView struct {
Manifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Provides struct {
Commands []string `json:"commands"`
Intents []string `json:"intents"`
} `json:"provides"`
Backend struct {
Mode string `json:"mode"`
} `json:"backend"`
} `json:"manifest"`
}
// WP3-I4:/api/plugins 合并编译插件条目——backend.mode=inproc、apiVersion 取
// 扩展面版本、provides 来自注册面摘要;目录插件同名时目录条目权威不重复。
func TestListPluginsMergesCompiled(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
pluginsRoot := filepath.Join(dir, "plugins")
// 目录插件与编译条目同名:目录条目权威,编译合成条目不重复列出。
if err := os.MkdirAll(filepath.Join(pluginsRoot, "generative-ui"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"id":"generative-ui","name":"目录版","version":"1.2.3","apiVersion":"1.0","provides":{}}`
if err := os.WriteFile(filepath.Join(pluginsRoot, "generative-ui", "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
ph := plugins.New(st, reg, setSvc, pluginsRoot)
for id, err := range ph.LoadDir() {
if err != nil {
t.Fatalf("LoadDir %s: %v", id, err)
}
}
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Auth = NewAuthenticator(st, true)
srv.CompiledPlugins = []CompiledPlugin{
{ID: "rdm", Commands: []string{"rdm.ingest", "rdm.inspect.capabilities"}, Intents: []string{"rdm.write-proposal"}},
{ID: "generative-ui", Commands: []string{"generative-ui.present"}},
}
h := srv.Handler()
key, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var list []compiledListView
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
var rdm, genui *compiledListView
genuiCount := 0
for i := range list {
switch list[i].Manifest.ID {
case "rdm":
rdm = &list[i]
case "generative-ui":
genui = &list[i]
genuiCount++
}
}
if rdm == nil {
t.Fatalf("compiled rdm entry missing: %s", rec.Body.String())
}
if rdm.Manifest.Name != "rdm" || rdm.Manifest.Backend.Mode != "inproc" {
t.Fatalf("rdm entry = %+v", rdm.Manifest)
}
if rdm.Manifest.APIVersion != extension.SurfaceVersion {
t.Fatalf("rdm apiVersion = %q, want %q", rdm.Manifest.APIVersion, extension.SurfaceVersion)
}
if len(rdm.Manifest.Provides.Commands) != 2 || len(rdm.Manifest.Provides.Intents) != 1 {
t.Fatalf("rdm provides = %+v", rdm.Manifest.Provides)
}
if genuiCount != 1 || genui == nil {
t.Fatalf("generative-ui listed %d times", genuiCount)
}
// 目录条目权威:同名编译条目不重复、manifest 不被合成版覆盖。
if genui.Manifest.Name != "目录版" || genui.Manifest.Version != "1.2.3" || genui.Manifest.Backend.Mode != "" {
t.Fatalf("genui entry = %+v", genui.Manifest)
}
}
+217
View File
@@ -0,0 +1,217 @@
package server
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
)
// 协作存储 U7 故障矩阵(§7「磁盘满/备份恢复/整机失效」行的磁盘满维度):
// 对象目录不可写时,写入类入口必须**清楚拒绝**(明确 5xx,绝不 2xx 静默或
// 半成品落库),且恢复后不留后遗症。注入不依赖 chmod(CI 容器以 root 运行,
// 0500 目录仍可写):把 artifacts 目录移走并在原位放置同名普通文件,
// blobstore 临时文件写入得到 ENOTDIR——与磁盘满同为 os.WriteFile 层的
// 持久性失败。
//
// 覆盖:
// 1. 上传(POST /api/projects/{id}/uploads)在对象目录失效时 5xx,
// artifacts 表零新增、对象目录零残留;恢复后同一上传成功。
// 2. 工件内容覆写(PUT /api/artifacts/{id}/content)同样拒绝,
// 且原内容保持可读(拒绝不产生半写状态)。
// breakArtifactsDir 将 <dir>/artifacts 移至 <dir>/artifacts.moved 并在原位
// 放置同名文件;返回恢复函数。
func breakArtifactsDir(t *testing.T, dataDir string) (restore func()) {
t.Helper()
dir := filepath.Join(dataDir, "artifacts")
moved := dir + ".moved"
if err := os.Rename(dir, moved); err != nil {
t.Fatalf("move artifacts dir: %v", err)
}
if err := os.WriteFile(dir, []byte("not a directory"), 0o644); err != nil {
t.Fatalf("plant file at artifacts path: %v", err)
}
return func() {
if err := os.Remove(dir); err != nil {
t.Fatalf("remove planted file: %v", err)
}
if err := os.Rename(moved, dir); err != nil {
t.Fatalf("restore artifacts dir: %v", err)
}
}
}
// diskFullServer 自建可控 dataDir 的服务(artifacts 根 = <dataDir>/artifacts,
// 见 store.OpenSQLite 的 finishOpen 约定),其余装配与 admission/e2e 一致。
func diskFullServer(t *testing.T) (http.Handler, *store.Store, string) {
t.Helper()
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, &runtime.MockRunner{}, &runtime.MockRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
t.Cleanup(mgr.Shutdown)
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Bus = cmdbus.New(st, nil, nil)
srv.WireBuiltinCommands()
return srv.Handler(), st, dir
}
func multipartUpload(t *testing.T, h http.Handler, projectID, filename string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(fw, bytes.NewReader([]byte("disk-full probe content: "+filename))); err != nil {
t.Fatal(err)
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func artifactCount(t *testing.T, st *store.Store, projectID string) (int, map[string]bool) {
t.Helper()
arts, err := st.ListArtifacts(store.OwnerDefault, projectID)
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, a := range arts {
names[a.Name] = true
}
return len(arts), names
}
func objectsIn(t *testing.T, dir string) map[string]bool {
t.Helper()
out := map[string]bool{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
out[e.Name()] = true
}
return out
}
func TestUploadRejectsCleanlyWhenObjectDirUnwritable(t *testing.T) {
h, st, dir := diskFullServer(t)
code, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "disk-full"})
if code != http.StatusOK {
t.Fatalf("create project: %d %v", code, out)
}
projID := out["id"].(string)
seed := multipartUpload(t, h, projID, "seed.txt")
if seed.Code != http.StatusOK {
t.Fatalf("seed upload: %d %.200s", seed.Code, seed.Body.String())
}
beforeCount, _ := artifactCount(t, st, projID)
beforeObjs := objectsIn(t, filepath.Join(dir, "artifacts"))
restore := breakArtifactsDir(t, dir)
rec := multipartUpload(t, h, projID, "probe-diskfull.txt")
if rec.Code < 500 {
t.Fatalf("upload under broken object dir: status %d (want explicit 5xx rejection), body %.200s", rec.Code, rec.Body.String())
}
if got, _ := artifactCount(t, st, projID); got != beforeCount {
t.Fatalf("rejected upload left DB rows: %d -> %d", beforeCount, got)
}
if got := objectsIn(t, filepath.Join(dir, "artifacts.moved")); len(got) != len(beforeObjs) {
t.Fatalf("rejected upload left object residue: %v -> %v", beforeObjs, got)
}
restore()
after := multipartUpload(t, h, projID, "probe-diskfull-recovered.txt")
if after.Code != http.StatusOK {
t.Fatalf("upload after recovery: %d %.200s", after.Code, after.Body.String())
}
if got, names := artifactCount(t, st, projID); got != beforeCount+1 || !names["probe-diskfull-recovered.txt"] {
t.Fatalf("recovered upload missing row: count %d names %v", got, names)
}
}
func TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable(t *testing.T) {
h, st, dir := diskFullServer(t)
code, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "disk-full-2"})
if code != http.StatusOK {
t.Fatalf("create project: %d %v", code, out)
}
projID := out["id"].(string)
seed := multipartUpload(t, h, projID, "base.txt")
if seed.Code != http.StatusOK {
t.Fatalf("seed upload: %d %.200s", seed.Code, seed.Body.String())
}
arts, err := st.ListArtifacts(store.OwnerDefault, projID)
if err != nil || len(arts) != 1 {
t.Fatalf("seed artifacts: %d err %v", len(arts), err)
}
artID := string(arts[0].ID)
getContent := func() string {
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+artID+"/content", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get content: %d %.200s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
before := getContent()
restore := breakArtifactsDir(t, dir)
put := httptest.NewRequest(http.MethodPut, "/api/artifacts/"+artID+"/content", bytes.NewReader([]byte("half-written?")))
putRec := httptest.NewRecorder()
h.ServeHTTP(putRec, put)
if putRec.Code < 500 {
t.Fatalf("overwrite under broken object dir: status %d (want explicit 5xx)", putRec.Code)
}
restore()
// 原位文件占位已撤;拒绝期间不允许半写污染读取路径。
if got := getContent(); got != before {
t.Fatalf("rejected overwrite altered readable content: %q -> %q", before, got)
}
put2 := httptest.NewRequest(http.MethodPut, "/api/artifacts/"+artID+"/content", bytes.NewReader([]byte("recovered write")))
put2Rec := httptest.NewRecorder()
h.ServeHTTP(put2Rec, put2)
if put2Rec.Code != http.StatusOK {
t.Fatalf("overwrite after recovery: %d %.200s", put2Rec.Code, put2Rec.Body.String())
}
if got := getContent(); got != "recovered write" {
t.Fatalf("content after recovered overwrite: %q", got)
}
}
+76
View File
@@ -0,0 +1,76 @@
package server
import (
"context"
"testing"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
)
// B 类硬化红测:button 入口命中写意图时不嵌套分发(无目标命令 receipt)。
func TestSystemIntentButtonNoNestedDispatch(t *testing.T) {
var writes int
b := cmdbus.New(nopAudit{}, nil, nil)
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话"},
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
writes++
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
if err := b.Intents().Declare(cmdbus.Intent{
ID: "demo.new", Phrases: []string{"新建会话"}, Command: "conversation.create", Plugin: "test",
}); err != nil {
t.Fatal(err)
}
res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
ID: "system.intent", Entrance: "button", Owner: "alice",
Params: map[string]any{"text": "请新建会话"},
})
if err != nil {
t.Fatal(err)
}
m, ok := res.(map[string]any)
if !ok || m["matched"] != true || m["dispatched"] != false {
t.Fatalf("button match must not dispatch: %+v", res)
}
if writes != 0 {
t.Fatalf("nested write command must not run, writes=%d", writes)
}
// chat 入口仍可嵌套分发(既有行为)
res2, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
ID: "system.intent", Entrance: "chat", Owner: "alice",
Params: map[string]any{"text": "请新建会话"},
})
if err != nil {
t.Fatal(err)
}
m2, _ := res2.(map[string]any)
if m2["dispatched"] != true || writes != 1 {
t.Fatalf("chat entrance should nest-dispatch: %+v writes=%d", res2, writes)
}
}
func TestSystemIntentUnmatchedCandidates(t *testing.T) {
b := cmdbus.New(nopAudit{}, nil, nil)
_ = b.Intents().Declare(cmdbus.Intent{
ID: "demo.x", Phrases: []string{"特定短语"}, Command: "builtin.help", Plugin: "test",
})
res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
Entrance: "button", Params: map[string]any{"text": "无关内容"},
})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]any)
if m["matched"] != false {
t.Fatalf("want unmatched, got %+v", res)
}
cands, _ := m["candidates"].([]map[string]any)
if len(cands) != 1 {
t.Fatalf("want 1 candidate, got %+v", m["candidates"])
}
}
+148
View File
@@ -2,12 +2,14 @@ package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
@@ -185,3 +187,149 @@ func messageText(m core.Message) string {
}
return b.String()
}
// ---- Generative UI Surface(提案 U2;EXT-17)。直接 API 与插件命令共用
// surfacesvc.Submit 单一咽喉:禁用/epoch/校验/CAS 守卫不因入口不同而绕过。----
func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
var body struct {
PluginID string `json:"pluginId"`
ConversationID string `json:"conversationId"`
SurfaceID string `json:"surfaceId"`
BaseRevision int `json:"baseRevision"`
ObservedEpoch int64 `json:"observedEpoch"`
ContextVersion string `json:"contextVersion"`
SemanticText string `json:"semanticText"`
Spec json.RawMessage `json:"spec"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PluginID == "" || len(body.Spec) == 0 {
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
return
}
snap, err := s.Surfaces.Submit(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec,
})
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusCreated, snap)
}
func (s *Server) getSurface(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, snap)
}
func (s *Server) listSurfaces(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
metas, err := s.Surfaces.List(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(metas))
}
func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "enabled required", http.StatusBadRequest)
return
}
row, err := s.Surfaces.SetActivation(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
var body struct {
ActionID string `json:"actionId"`
Revision int `json:"revision"`
RequestID string `json:"requestId"`
Data map[string]any `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ActionID == "" || body.RequestID == "" {
http.Error(w, "actionId and requestId required", http.StatusBadRequest)
return
}
res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID,
Revision: body.Revision, RequestID: body.RequestID, Data: body.Data,
})
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// writeSurfaceError 把服务层确定错误映射到状态码:禁用/epoch=409 语义(准入
// 截止),规格违规=400 带字段清单,冲突=409 带当前版本,NotFound=404。
func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) {
var invalid *surfacesvc.ErrInvalidSpec
var conflict *store.ErrRevisionConflict
switch {
case errors.Is(err, surfacesvc.ErrDisabled), errors.Is(err, surfacesvc.ErrStaleEpoch),
errors.Is(err, surfacesvc.ErrUnavailable):
http.Error(w, err.Error(), http.StatusForbidden)
case errors.Is(err, surfacesvc.ErrUnknownAction):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, store.ErrIdempotencyConflict):
http.Error(w, err.Error(), http.StatusConflict)
case errors.As(err, &invalid):
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid spec", "details": invalid.Errs})
case errors.As(err, &conflict):
writeJSON(w, http.StatusConflict, map[string]any{"error": "revision conflict", "current": conflict.Current})
case errors.Is(err, store.ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。
func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(metas))
}
+205 -31
View File
@@ -14,6 +14,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -31,8 +32,10 @@ import (
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
"agent-system-backend/pkg/extension"
"agent-system-backend/web"
core "agent-system-core"
)
@@ -45,11 +48,12 @@ type Server struct {
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
mgr *runmgr.Manager
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
reg *tools.Registry
prof *profiles.Service
set *settings.Service
@@ -63,13 +67,54 @@ type Server struct {
// ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口
// owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。
ProjectAuthz func(owner string, projectID core.ID) (bool, error)
// ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对
// 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
// 计划见 docs/guides/team-storage.md(KTD13 一节)。
ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error)
// CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled
// 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。
CompiledPlugins []CompiledPlugin
}
// ProjectAction 项目资源的动作授权矩阵(KTD13)。共享可读不等于可写或可
// 批准:动作语义独立判定,未配置的动作默认拒绝。
type ProjectAction string
const (
ProjectRead ProjectAction = "read" // 读取项目资源(会话/消息/检索/工件下载)
ProjectEdit ProjectAction = "edit" // 写入与修改(会话内发消息、卡片裁决、设置变更)
ProjectApprove ProjectAction = "approve" // 审批(HITL 确认、命令确认)
ProjectExecute ProjectAction = "execute" // 触发执行(run、工具、工作流)
ProjectAdmin ProjectAction = "admin" // 管理(成员、共享范围、销毁性操作)
)
// SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。
func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) {
s.ProjectAuthz = fn
}
// SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约
// 扩展落地前,宿主侧先以本槽位提供矩阵语义)。
func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) {
s.ProjectAuthzActions = fn
}
// authorizeProjectAction 动作矩阵唯一判定(KTD13):
// - action-aware 适配器已注册 → 权威(出错/拒绝一律 false,fail-closed);
// - 未注册 → read 回落旧只读 ProjectAuthz(兼容 EXT-8 现网),其余动作
// 默认拒绝(旧适配器不得自动获得写入能力)。
func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action ProjectAction) bool {
if s.ProjectAuthzActions != nil {
ok, err := s.ProjectAuthzActions(owner, projectID, action)
return err == nil && ok
}
if action == ProjectRead {
return s.authorizeSharedProject(owner, projectID)
}
return false
}
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
// (fail-closed;错误不区分,避免泄露存在性)。
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
@@ -122,32 +167,10 @@ func (s *Server) WireBuiltinCommands() {
return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助", len(b.List()))}, nil
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单;button 入口只读匹配、不嵌套分发",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
"/intent", func(ctx context.Context, req cmdbus.Request) (any, error) {
text, _ := req.Params["text"].(string)
in, ok := b.Intents().Match(text)
if !ok {
list := b.Intents().List()
cands := make([]map[string]any, 0, len(list))
for _, x := range list {
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
}
return map[string]any{"matched": false, "candidates": cands}, nil
}
params := map[string]any{}
for k, v := range in.Params {
params[k] = v
}
for k, v := range req.Params {
if k != "text" {
params[k] = v // 调用方可覆盖模板参数
}
}
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
"receipt": resp.Receipt, "payload": resp.Payload}, nil
return handleSystemIntent(ctx, b, req)
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
@@ -191,6 +214,38 @@ func (s *Server) WireBuiltinCommands() {
}))
}
// handleSystemIntent 实现 system.intent:命中后默认嵌套分发目标命令。
// B 类硬化:Entrance=button(页面动作)只返回匹配结果,禁止嵌套业务写路径。
func handleSystemIntent(ctx context.Context, b *cmdbus.Bus, req cmdbus.Request) (any, error) {
text, _ := req.Params["text"].(string)
in, ok := b.Intents().Match(text)
if !ok {
list := b.Intents().List()
cands := make([]map[string]any, 0, len(list))
for _, x := range list {
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
}
return map[string]any{"matched": false, "candidates": cands}, nil
}
if agentcommand.CanonicalEntrance(req.Entrance) == "button" {
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
"dispatched": false, "note": "button entrance: match only, no nested dispatch"}, nil
}
params := map[string]any{}
for k, v := range in.Params {
params[k] = v
}
for k, v := range req.Params {
if k != "text" {
params[k] = v
}
}
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
"dispatched": true, "receipt": resp.Receipt, "payload": resp.Payload}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
@@ -272,9 +327,18 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/tools/{name}", s.deleteUserTool)
mux.HandleFunc("GET /api/plugins", s.listPlugins)
mux.HandleFunc("POST /api/plugins/{id}/enabled", s.setPluginEnabled)
mux.HandleFunc("POST /api/plugins/{id}/activation", s.setSurfaceActivation)
mux.HandleFunc("POST /api/surfaces", s.submitSurface)
mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface)
mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction)
mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces)
mux.HandleFunc("GET /api/projects/{id}/surfaces", s.listProjectSurfaces)
mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins)
mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema)
mux.HandleFunc("GET /api/registry", s.registry)
// WP3-I3/KTD4:管理区声明分区清单(编译插件经 Registry.AddAdminZone 声明;
// 展示元数据对所有认证主体开放,分区内数据能力由组件命令面 fail-closed)。
mux.HandleFunc("GET /api/admin-zone/sections", s.listAdminZoneSections)
// ---- 计划 / 待办 / 命令 / 解析器 / 检索 ----
mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan)
@@ -303,6 +367,8 @@ func (s *Server) Handler() http.Handler {
// ---- 管理(嵌入式档在线备份)----
mux.HandleFunc("POST /api/admin/backup", s.backup)
mux.HandleFunc("GET /api/admin/recovery-holds", s.listRecoveryHolds)
mux.HandleFunc("POST /api/admin/recovery-holds/{runId}/release", s.releaseRecoveryHold)
// API 未匹配路径一律 404(不回落 SPA,避免 /api/* 返回 HTML 200)。
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
@@ -560,13 +626,14 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
}
// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。
// authVia 区分凭证通道:改密门只对会话登录者放行,key 持有者不能改密(无密码属主)。
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
name := owner
if u, err := s.st.GetUser(owner); err == nil && u.Name != "" {
name = u.Name
}
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled}
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)}
if st, err := s.st.GetUserAuthState(owner); err == nil {
out["isAdmin"] = st.IsAdmin
out["mustChangePassword"] = st.MustChangePassword
@@ -765,6 +832,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
}
claimed := false
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
// 执行许可预检(U3,R8):待执行队列已满时 429 且不写任何数据
//(与忙 409 同位;排队中的接纳不受影响)。
if s.mgr.QueueFull() {
http.Error(w, "execution queue full(runtime.execQueueMax)", http.StatusTooManyRequests)
return
}
if err := s.mgr.ClaimConversation(convID); err != nil {
writeStoreErr(w, err)
return
@@ -862,6 +935,7 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: id, Params: params, Entrance: "slash", Actor: s.owner(r),
Owner: s.owner(r), ConversationID: conv.ID,
HostProjectID: string(conv.ProjectID),
})
out := map[string]any{"command": slash, "receipt": resp.Receipt}
if m, ok := resp.Payload.(map[string]any); ok {
@@ -919,9 +993,16 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
if body.Params == nil {
body.Params = map[string]any{}
}
var hostProject string
if body.ConversationID != "" {
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
hostProject = string(conv.ProjectID)
}
}
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
HostProjectID: hostProject,
})
code := http.StatusOK
if resp.Receipt.State == agentcommand.StateRejected {
@@ -954,7 +1035,27 @@ func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.mgr.CancelRun(r.PathValue("id")))
runID := r.PathValue("id")
// 跨实例取消(协作存储 U5/KTD5)**前置**:run 不在本实例执行(无句柄)
// 且 PG 档——写持久命令,由执行实例消费;API 立即受理(202)。
// 不得先调本地 CancelRun:非执行实例无句柄时它返回 nil 的「假成功」
// 会吞掉命令路径,run 永远停不下(U7 双进程验收实测)。
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
// 无法送达(同批实测)。
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
convID := ""
if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil {
convID = string(run.ConversationID)
}
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0,
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil {
writeStoreErr(w, cerr)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
return
}
writeOKOr(w, s.mgr.CancelRun(runID))
}
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
@@ -977,6 +1078,21 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
}
resp.CardID = r.PathValue("id")
pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp)
if err != nil && s.st.Dialect() == "postgres" {
// 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例
// 消费时重验 gate(cardID+gateVersion),过期审批明确拒绝。
if card, gerr := s.st.GetHITLCard(s.owner(r), string(resp.CardID)); gerr == nil && !card.Resolved {
kind := resp.ChoiceID
if kind == "" {
kind = "free"
}
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "hitl", string(card.RunID), string(card.ConversationID), 0,
runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}); cerr == nil {
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
return
}
}
}
if err != nil {
writeStoreErr(w, err)
return
@@ -1354,6 +1470,28 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
// ---- 插件 ----
// CompiledPlugin 是一个编译进二进制的进程内插件的注册面摘要(WP3-I4):
// ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。
// 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。
type CompiledPlugin struct {
ID string
Commands []string
Intents []string
Skills []string
AdminZones []AdminZoneSection
}
// AdminZoneSection 是编译插件声明的管理区展示分区(WP3-I3/KTD4):
// 字段与 extension.AdminZoneSpec 对齐(组合根装载期记录);入口不做基座级
// 权限判定,分区内数据能力由组件自身命令面 fail-closed。
type AdminZoneSection struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Desc string `json:"desc,omitempty"`
Order int `json:"order,omitempty"`
}
type pluginView struct {
Manifest core.PluginManifest `json:"manifest"`
Panels []core.PluginPanel `json:"panels"`
@@ -1369,13 +1507,28 @@ type pluginView struct {
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
var out []pluginView
dirIDs := map[string]bool{}
for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) {
dirIDs[ld.Manifest.ID] = true
out = append(out, pluginView{
Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards,
Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings,
Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills,
})
}
// 编译插件(WP3-I4):条目由注册面合成——backend.mode=inproc 标记编译
// 来源,apiVersion 取扩展面版本;与目录插件同名时目录 manifest 权威
// (目录版本可装卸、信息更全),编译条目不重复列出。
for _, cp := range s.CompiledPlugins {
if dirIDs[cp.ID] {
continue
}
out = append(out, pluginView{Manifest: core.PluginManifest{
ID: cp.ID, Name: cp.ID, APIVersion: extension.SurfaceVersion,
Provides: core.PluginProvides{Commands: cp.Commands, Intents: cp.Intents, Skills: cp.Skills},
Backend: core.PluginBackend{Mode: "inproc"},
}})
}
writeJSON(w, http.StatusOK, orEmptySlice(out))
}
@@ -1388,6 +1541,23 @@ func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
}
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
// 声明并按 Order→ID 稳定排序。声明是展示元数据,对所有认证主体一致;
// 分区内可见数据由分区组件自身的命令面按 owner fail-closed 判定。
func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
var out []AdminZoneSection
for _, cp := range s.CompiledPlugins {
out = append(out, cp.AdminZones...)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Order != out[j].Order {
return out[i].Order < out[j].Order
}
return out[i].ID < out[j].ID
})
writeJSON(w, http.StatusOK, orEmptySlice(out))
}
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
reg := map[string]any{
@@ -1611,6 +1781,10 @@ func writeStoreErr(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if errors.Is(err, runmgr.ErrAdmissionQueueFull) {
http.Error(w, err.Error(), http.StatusTooManyRequests)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
}
+149
View File
@@ -0,0 +1,149 @@
package server
// Generative UI U2 红测(提案 KTD7):直接 API 与插件命令共用 surfacesvc
// 单一咽喉——禁用状态下 POST /api/surfaces 必须被拒(不能因入口绕过执行级
// 启禁),读取与启禁端点行为符合「历史保留 + epoch 纪元」语义。
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/surfacesvc"
)
func TestSurfaceDirectAPIGuards(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
svc := surfacesvc.New(st, nil)
srv.Surfaces = svc
post := func(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
get := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
// 未启用:提交被拒(403),提示能力存在但插件未启用
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
t.Fatalf("disabled submit should 403, got %d: %s", rec.Code, rec.Body.String())
}
// 启用(activation 端点走服务层,epoch=1)
if rec := post("/api/plugins/genui/activation", `{"enabled":true}`); rec.Code != http.StatusOK {
t.Fatalf("enable failed: %d %s", rec.Code, rec.Body.String())
}
// 提交成功 → 201
rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`)
if rec.Code != http.StatusCreated {
t.Fatalf("enabled submit should 201, got %d: %s", rec.Code, rec.Body.String())
}
var snap struct {
Identity struct {
SurfaceID string `json:"surfaceId"`
} `json:"identity"`
Revision int `json:"revision"`
}
readJSON(t, rec.Body.Bytes(), &snap)
// 读取可用;列表可用
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
t.Fatalf("get failed: %d", rec.Code)
}
if rec := get("/api/conversations/c1/surfaces"); rec.Code != http.StatusOK {
t.Fatalf("list failed: %d", rec.Code)
}
// 禁用后:提交 403;读取仍 200(历史保留)
if rec := post("/api/plugins/genui/activation", `{"enabled":false}`); rec.Code != http.StatusOK {
t.Fatalf("disable failed: %d", rec.Code)
}
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
t.Fatalf("post-disable submit should 403, got %d", rec.Code)
}
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
t.Fatalf("post-disable read should stay 200, got %d", rec.Code)
}
// 未带凭据:401(认证面同现有守卫)
if rec := httptest.NewRecorder(); true {
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"genui"}`))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated should 401, got %d", rec.Code)
}
}
}
// U5:动作端点红测——禁用 403、未知动作 400、端点不绑定 confirmed(伪造无入口)。
func TestSurfaceActionEndpoint(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
key, _ := st.CreateAPIKey("alice", "e2e")
svc := surfacesvc.New(st, nil)
svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil
})
srv.Surfaces = svc
ctx := context.Background()
svc.SetActivation(ctx, "alice", "generative-ui", true)
spec := `"spec":{"title":"表单页","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"generative-ui","conversationId":"c1",`+spec+`}`))
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := post("")
if rec.Code != http.StatusCreated {
t.Fatalf("setup submit failed: %d %s", rec.Code, rec.Body.String())
}
var snap struct {
Identity struct {
SurfaceID string `json:"surfaceId"`
} `json:"identity"`
Revision int `json:"revision"`
}
readJSON(t, rec.Body.Bytes(), &snap)
act := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", bytes.NewBufferString(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 未知动作 → 400
if r := act(`{"actionId":"shell.exec","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r1","data":{}}`); r.Code != http.StatusBadRequest {
t.Fatalf("unknown action should 400, got %d: %s", r.Code, r.Body.String())
}
// 伪造 confirmed 字段:请求体里带 confirmed:true 不产生任何效果(端点不绑定
// 该字段;DangerHigh 确认只能经确认卡回道)。此处 submit 非 DangerHigh,
// 断言 confirmed 被忽略(成功且不因它改变行为)。
if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r2","data":{"a":"1"},"confirmed":true}`); r.Code != http.StatusOK {
t.Fatalf("submit with forged confirmed should still be normal submit, got %d: %s", r.Code, r.Body.String())
}
// 禁用后 → 403
svc.SetActivation(ctx, "alice", "generative-ui", false)
if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r3","data":{}}`); r.Code != http.StatusForbidden {
t.Fatalf("post-disable action should 403, got %d", r.Code)
}
_ = fmt.Sprint
}
+23 -11
View File
@@ -34,14 +34,16 @@ func SystemDefaults() map[string]any {
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
},
"runtime": map[string]any{
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
"verify": true, // 终轮确定性检查与独立验证
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
"codeOrchestration": false, // 模型用脚本批量调用工具;默认关
"mock": false, // true = 全部会话走 mock runner(演示/测试)
"squad": true, // 小队编排(Agent-Squad 引擎)可用
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
"verify": true, // 终轮确定性检查与独立验证
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
"codeOrchestration": false, // 模型用脚本批量调用工具;默认关
"materialIntake": true, // 附件即保证:带附件回合系统确定性启动材料整理后台任务
"materialIntakeChars": 12000, // 带文字发送时材料总量达到该字符数也预启动(否则回合结束兜底)
},
"storage": map[string]any{
"dataDir": "./data", // 启动注入实际值(--data)
@@ -57,13 +59,18 @@ func SystemDefaults() map[string]any {
"read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读
},
"retrieval": map[string]any{"topK": 5},
"ui": map[string]any{"theme": "dark", "cardflow": false},
"ui": map[string]any{"theme": "dark", "cardflow": true}, // 长产物进卡片默认开(用户 2026-10-11 拍板)
"deliverables": map[string]any{
"style": "modern", // modern / formal / minimal
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
},
"plugins": map[string]any{"dir": "./plugins"},
"plugins": map[string]any{
"dir": "./plugins",
"generative-ui": map[string]any{
"autoPresent": false, // 自动建议页面:默认关;需同时开启 Surface activation
},
},
}
}
@@ -91,12 +98,16 @@ func Schema() []core.SettingDesc {
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
{Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"},
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
{Key: "runtime.dualSpeedNudgeSeconds", Group: "runtime", Label: "快慢双速提醒阈值(秒)", Type: core.SettingNumber, Default: 75, Effect: core.EffectNewRun, Description: "任务连续运行超过该秒数、用户还没收到任何答复、也没把深度工作放后台时,一次性提醒模型转快慢双速:先把判断/概要/计划回复给用户,重活用 delegate_task 的 background 放后台(SR-B10)。0 = 关闭"},
{Key: "runtime.materialIntake", Group: "runtime", Label: "附件即材料整理", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "带附件的发送由系统确定性启动材料整理后台任务(无文字或大材料立即启动;文字+小材料回合结束兜底),进度卡即时入收件箱。关闭 = 回到模型自行判断"},
{Key: "runtime.materialIntakeChars", Group: "runtime", Label: "材料预启动阈值(字符)", Type: core.SettingNumber, Default: 12000, Effect: core.EffectNewRun, Description: "带文字发送时,附件解析文本总量达到该字符数也立即预启动材料整理;未达到则模型先答、回合结束兜底"},
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
@@ -112,7 +123,7 @@ func Schema() []core.SettingDesc {
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"},
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"},
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
@@ -120,6 +131,7 @@ func Schema() []core.SettingDesc {
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
}
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// 协作存储 U3 切片 2(R8/KTD「接纳与执行许可分别计数」):执行许可的共享
// 权威落在数据库(admission_leases),多实例天然共享;接纳数(排队中的 run)
// 由 runmgr 的有界队列管理,不进本表。预算关闭(全零)时不产生任何租约行。
// ExecutionBudget 执行许可预算。零值 = 不限(本地档默认,行为与历史一致)。
type ExecutionBudget struct {
Global int // 全局并发执行上限(含子代理与索引任务);0=不限
PerUser int // 每租户并发执行上限;0=不限
}
// Off 表示预算完全关闭(不限流、不留租约)。
func (b ExecutionBudget) Off() bool { return b.Global <= 0 && b.PerUser <= 0 }
// ErrBudgetExceeded 预算已满(可排队);ErrAdmissionQueueFull 是其排队也满的
// 上层形态(runmgr 定义,映射 429)。
var ErrBudgetExceeded = errors.New("store: execution budget exceeded")
// TryAcquireExecutionSlot 原子获取(或重新激活)run 的执行许可:在 admission
// 咨询锁下清点活跃租约(全局 + 租户),不超预算才登记。父 run 让出后重新
// 获取走同一路径(upsert 复活既有行)。
func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBudget) error {
if budget.Off() {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := s.lockAdmission(tx); err != nil {
return err
}
var global, perUser int
if budget.Global > 0 {
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND run_id<>?`), runID).Scan(&global); err != nil {
return err
}
if global >= budget.Global {
return fmt.Errorf("exec slot %s: %w", runID, ErrBudgetExceeded)
}
}
if budget.PerUser > 0 {
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND owner=? AND run_id<>?`), owner, runID).Scan(&perUser); err != nil {
return err
}
if perUser >= budget.PerUser {
return fmt.Errorf("exec slot %s: %w", runID, ErrBudgetExceeded)
}
}
if _, err := tx.Exec(s.q(`INSERT INTO admission_leases (run_id, owner, acquired_at, released_at) VALUES (?,?,?,NULL)
ON CONFLICT(run_id) DO UPDATE SET owner=excluded.owner, acquired_at=excluded.acquired_at, released_at=NULL`),
runID, owner, nowISO()); err != nil {
return err
}
return tx.Commit()
}
// ReleaseExecutionSlot 释放执行许可(幂等):父 run 等待子代理/HITL 时让出,
// 恢复执行须重新获取(方案 KTD「让出执行许可、保留会话所有权」)。
func (s *Store) ReleaseExecutionSlot(runID string) error {
_, err := s.db.Exec(s.q(`UPDATE admission_leases SET released_at=? WHERE run_id=? AND released_at IS NULL`), nowISO(), runID)
return err
}
// ActiveExecutionSlots 返回活跃执行许可数(诊断/验收:全局与按租户)。
func (s *Store) ActiveExecutionSlots() (global int, byOwner map[string]int, err error) {
byOwner = map[string]int{}
rows, err := s.db.Query(s.q(`SELECT owner, COUNT(*) FROM admission_leases WHERE released_at IS NULL GROUP BY owner`))
if err != nil {
return 0, nil, err
}
defer rows.Close()
for rows.Next() {
var o string
var n int
if err := rows.Scan(&o, &n); err != nil {
return 0, nil, err
}
byOwner[o] = n
global += n
}
return global, byOwner, rows.Err()
}
// lockAdmission 在事务内锁定执行许可账本(PG 事务级咨询锁;SQLite 单写者
// 天然串行)。与 lockConversation/lockScope 同模式(dialect 收敛)。
func (s *Store) lockAdmission(tx *sql.Tx) error {
return s.dialect.lockAdmission(tx)
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"context"
"errors"
"sync"
"testing"
)
// 协作存储 U3 切片 2(R8):执行许可账本。预算判定原子性、让出/复活、
// 并发恰 N 个赢家。双档(PG 由 CI 真库 job 覆盖咨询锁与部分索引)。
func TestExecutionBudgetEnforced(t *testing.T) {
st := openTestStore(t)
budget := ExecutionBudget{Global: 2}
if err := st.TryAcquireExecutionSlot("run_1", "o", budget); err != nil {
t.Fatal(err)
}
if err := st.TryAcquireExecutionSlot("run_2", "o", budget); err != nil {
t.Fatal(err)
}
err := st.TryAcquireExecutionSlot("run_3", "o", budget)
if !errors.Is(err, ErrBudgetExceeded) {
t.Fatalf("global=2 时第三个应拒绝:%v", err)
}
// 释放一个 → 第三个可进。
if err := st.ReleaseExecutionSlot("run_1"); err != nil {
t.Fatal(err)
}
if err := st.TryAcquireExecutionSlot("run_3", "o", budget); err != nil {
t.Fatal(err)
}
// 复活(父让出后重取):释放 run_2 再复活 run_2。
if err := st.ReleaseExecutionSlot("run_2"); err != nil {
t.Fatal(err)
}
if err := st.TryAcquireExecutionSlot("run_2", "o", budget); err != nil {
t.Fatal(err)
}
global, byOwner, err := st.ActiveExecutionSlots()
if err != nil || global != 2 || byOwner["o"] != 2 {
t.Fatalf("活跃账本:global=%d byOwner=%v err=%v", global, byOwner, err)
}
// 预算关闭:零开销直通、不留租约。
if err := st.TryAcquireExecutionSlot("run_9", "o", ExecutionBudget{}); err != nil {
t.Fatal(err)
}
}
func TestExecutionBudgetPerUser(t *testing.T) {
st := openTestStore(t)
budget := ExecutionBudget{PerUser: 1}
if err := st.TryAcquireExecutionSlot("run_a", "alice", budget); err != nil {
t.Fatal(err)
}
if err := st.TryAcquireExecutionSlot("run_b", "bob", budget); err != nil {
t.Fatal(err)
}
err := st.TryAcquireExecutionSlot("run_c", "alice", budget)
if !errors.Is(err, ErrBudgetExceeded) {
t.Fatalf("perUser=1 时 alice 第二个应拒绝:%v", err)
}
// 同 run 重取不占新额度(复活语义)。
if err := st.TryAcquireExecutionSlot("run_a", "alice", budget); err != nil {
t.Fatal(err)
}
}
// 并发获取恰 N 个赢家:预算 3、12 个竞争者 → 3 成功 9 拒绝(原子清点,
// 不超卖;PG 路径验证咨询锁串行化)。
func TestExecutionBudgetConcurrentNoOversell(t *testing.T) {
st := openTestStore(t)
budget := ExecutionBudget{Global: 3}
const racers = 12
var mu sync.Mutex
ok, denied := 0, 0
var wg sync.WaitGroup
for i := 0; i < racers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
err := st.TryAcquireExecutionSlot(newID("run"), "o"+string(rune('a'+i%3)), budget)
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
ok++
case errors.Is(err, ErrBudgetExceeded):
denied++
default:
t.Errorf("意外错误:%v", err)
}
}(i)
}
wg.Wait()
if ok != 3 || denied != racers-3 {
t.Fatalf("应恰 3 成功:ok=%d denied=%d", ok, denied)
}
}
// 迁移到位验收(v21 表存在)由 TestMigrationUpgradesExistingDatabase 的
// 全量账本断言覆盖;此处补空库→全量后账本可清点的最小回归。
func TestAdmissionLeasesTablePresent(t *testing.T) {
st := openTestStore(t)
global, _, err := st.ActiveExecutionSlots()
if err != nil || global != 0 {
t.Fatalf("空账本:global=%d err=%v", global, err)
}
_ = context.Background()
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"errors"
"sync"
"testing"
"time"
core "agent-system-core"
)
// 协作存储 U3(KTD7):卡片乐观并发。同版本竞争写入最多一个成功,
// 其余明确 ErrConflict(HTTP 层映射 409);盲写(系统镜像)递增版本。
func newVersionedCard(t *testing.T) (core.ActivityCard, string) {
t.Helper()
return core.ActivityCard{
ID: NewID("card"), Title: "版本卡", Status: core.CardActive,
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}, "o"
}
func TestCardVersionRoundTrip(t *testing.T) {
st := openTestStore(t)
c, owner := newVersionedCard(t)
if err := st.SaveCard(owner, &c, ""); err != nil {
t.Fatal(err)
}
v, err := st.CardVersion(owner, c.ID)
if err != nil || v != 1 {
t.Fatalf("建卡版本应为 1:v=%d err=%v", v, err)
}
// 乐观更新:expected 一致 → 成功且递增。
if err := st.SaveCardVersioned(owner, &c, "", 1); err != nil {
t.Fatal(err)
}
if v, _ = st.CardVersion(owner, c.ID); v != 2 {
t.Fatalf("版本应递增到 2:%d", v)
}
// 旧版本重放:明确冲突。
err = st.SaveCardVersioned(owner, &c, "", 1)
if !errors.Is(err, ErrConflict) {
t.Fatalf("旧版本应 ErrConflict:%v", err)
}
// 盲写同样递增(系统镜像路径)。
if err := st.SaveCard(owner, &c, ""); err != nil {
t.Fatal(err)
}
if v, _ = st.CardVersion(owner, c.ID); v != 3 {
t.Fatalf("盲写应递增到 3:%d", v)
}
}
// 并发同版本竞争:N 个写者同 expected 提交,恰一个成功,其余 ErrConflict;
// 不存在静默覆盖(R3 验收)。
func TestCardVersionConcurrentSingleWinner(t *testing.T) {
st := openTestStore(t)
c, owner := newVersionedCard(t)
if err := st.SaveCard(owner, &c, ""); err != nil {
t.Fatal(err)
}
const writers = 8
var mu sync.Mutex
ok, conflict := 0, 0
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
dup := c
dup.Title = "并发写"
err := st.SaveCardVersioned(owner, &dup, "", 1)
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
ok++
case errors.Is(err, ErrConflict):
conflict++
default:
t.Errorf("意外错误:%v", err)
}
}(i)
}
wg.Wait()
if ok != 1 || conflict != writers-1 {
t.Fatalf("应恰一个成功:ok=%d conflict=%d", ok, conflict)
}
if v, _ := st.CardVersion(owner, c.ID); v != 2 {
t.Fatalf("竞争后版本应为 2:%d", v)
}
// cardsvc 读侧:GetCardVersion 返回卡与版本。
_, ver, err := st.GetCardVersion(owner, c.ID)
if err != nil || ver != 2 {
t.Fatalf("GetCardVersion:ver=%d err=%v", ver, err)
}
}
+59 -3
View File
@@ -19,9 +19,9 @@ func (s *Store) SaveCard(owner string, c *core.ActivityCard, ref string) error {
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
_, err = s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at`),
_, err = s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
VALUES (?,?,?,?,?,?,?,?,?,?,1)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1`),
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
@@ -29,6 +29,62 @@ func (s *Store) SaveCard(owner string, c *core.ActivityCard, ref string) error {
return nil
}
// SaveCardVersioned 乐观并发保存(U3,KTD7):行存在且 expected 与当前
// version 一致才应用并递增,否则 ErrConflict(调用方回 409 让客户端重读
// 重试);行不存在时按新卡插入 version=1(expected==0 的建卡语义)。
// 现产品没有卡片删除路径,expected>0 而行缺失不可达;若未来引入删除,
// 此处须补「插入回滚为冲突」的语义。
func (s *Store) SaveCardVersioned(owner string, c *core.ActivityCard, ref string, expected int) error {
payload, err := json.Marshal(c)
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
res, err := s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
VALUES (?,?,?,?,?,?,?,?,?,?,1)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1
WHERE activity_cards.version=?`),
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano), expected)
if err != nil {
return fmt.Errorf("save card %s: %w", c.ID, err)
}
if n, _ := res.RowsAffected(); n == 0 {
// DO UPDATE 的 WHERE 未命中:行存在但版本不匹配 → 明确冲突(客户端重读)。
return fmt.Errorf("save card %s: %w", c.ID, ErrConflict)
}
return nil
}
// CardVersion 返回当前版本号(乐观并发的读侧)。
func (s *Store) CardVersion(owner, id string) (int, error) {
var v int
err := s.db.QueryRow(s.q(`SELECT version FROM activity_cards WHERE id=? AND owner=?`), id, owner).Scan(&v)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
return 0, err
}
return v, nil
}
// GetCardVersion 读卡与当前版本(乐观并发的读侧;U3 KTD7)。
func (s *Store) GetCardVersion(owner, id string) (core.ActivityCard, int, error) {
row := s.db.QueryRow(s.q(`SELECT payload, version FROM activity_cards WHERE id=? AND owner=?`), id, owner)
var payload string
var ver int
if err := row.Scan(&payload, &ver); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return core.ActivityCard{}, 0, ErrNotFound
}
return core.ActivityCard{}, 0, err
}
var c core.ActivityCard
if err := json.Unmarshal([]byte(payload), &c); err != nil {
return core.ActivityCard{}, 0, fmt.Errorf("card %s payload: %w", id, err)
}
return c, ver, nil
}
func (s *Store) GetCard(owner, id string) (core.ActivityCard, error) {
row := s.db.QueryRow(s.q(`SELECT payload FROM activity_cards WHERE id=? AND owner=?`), id, owner)
var payload string
+8
View File
@@ -15,6 +15,8 @@ type dialect interface {
lockConversation(tx *sql.Tx, conversationID string) error
// lockScope 锁定设置 scope 的读改写(owner+scope)。
lockScope(tx *sql.Tx, owner, scope string) error
// lockAdmission 锁定执行许可账本的清点与登记(U3 admission)。
lockAdmission(tx *sql.Tx) error
}
// ---- SQLite(嵌入式档)----
@@ -25,6 +27,7 @@ func (sqliteDialect) name() string { return "sqlite"
func (sqliteDialect) rebind(q string) string { return q }
func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil }
func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil }
func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil }
// ---- PostgreSQL(多用户/多实例档)----
@@ -55,3 +58,8 @@ func (postgresDialect) lockScope(tx *sql.Tx, owner, scope string) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "scope:"+owner+"/"+scope)
return err
}
func (postgresDialect) lockAdmission(tx *sql.Tx) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "admission:exec-slots")
return err
}
+237
View File
@@ -0,0 +1,237 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// 协作存储 U5(KTD4/KTD5):会话执行租约 + 持久命令收件箱。
//
// 租约:conversation_executions 一行一会话。TryClaimExecution 空闲/租约到期
// 才可占用;接管(含同实例重新占用)递增 epoch——旧 owner 的续租与结算按
//(instance, epoch)双重校验被拒(fencing)。lease_until 用应用侧 UTC ISO
// (单库写入方一致即可;跨库时钟偏移属部署合同,见 runbook)。
//
// 命令:run_commands 收件箱承载跨实例取消/HITL(KTD5)。command_id 幂等;
// pending→consumed 由消费方原子认领(PG FOR UPDATE SKIP LOCKED;SQLite 单写
// 者条件更新)。命令不删除——审计与重放判定保留。
// ErrExecutionBusy 会话执行权被其他实例持有且租约未到期。
var ErrExecutionBusy = errors.New("store: execution owned by another instance")
// ErrFenced 旧执行代次的写入被围栏拒绝(epoch 已被接管递增)。
var ErrFenced = errors.New("store: fenced by newer execution epoch")
// ExecutionState 当前执行租约快照(诊断/测试)。
type ExecutionState struct {
ConversationID string
InstanceID string
RunID string
Epoch int64
LeaseUntil string
}
// TryClaimExecution 占用(或租约到期后接管)会话执行权。
// 返回接管后的 epoch(接管必然 +1)。被他人持有且未到期 → ErrExecutionBusy。
func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leaseSec int) (int64, error) {
if leaseSec <= 0 {
leaseSec = 90
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if err := s.lockAdmission(tx); err != nil { // 复用全局账本锁串行化清点
return 0, err
}
now := time.Now().UTC()
until := now.Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
var inst, lease string
var epoch int64
err = tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.Exec(s.q(`INSERT INTO conversation_executions (conversation_id, instance_id, run_id, epoch, lease_until, updated_at) VALUES (?,?,?,1,?,?)`),
conversationID, instanceID, runID, until, nowISO()); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return 1, nil
case err != nil:
return 0, err
}
if inst != instanceID && lease > nowISO() {
return epoch, fmt.Errorf("conv %s: %w", conversationID, ErrExecutionBusy)
}
// 接管(到期或同实例重占):epoch+1,fencing 由此产生。
if _, err := tx.Exec(s.q(`UPDATE conversation_executions SET instance_id=?, run_id=?, epoch=epoch+1, lease_until=?, updated_at=? WHERE conversation_id=?`),
instanceID, runID, until, nowISO(), conversationID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return epoch + 1, nil
}
// RenewExecution 续租(fencing:instance+epoch 双校验)。假 = 已被接管或释放。
func (s *Store) RenewExecution(conversationID, instanceID string, epoch int64, leaseSec int) bool {
if leaseSec <= 0 {
leaseSec = 90
}
until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
res, err := s.db.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
until, nowISO(), conversationID, instanceID, epoch)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
// ReleaseExecution 主动释放(优雅停机):行保留、租约置为已过期——epoch 保留,
// 下一次占用仍递增(fencing 单调)。
func (s *Store) ReleaseExecution(conversationID, instanceID string, epoch int64) {
_, _ = s.db.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
time.Now().UTC().Add(-time.Second).UTC().Format(time.RFC3339Nano), nowISO(), conversationID, instanceID, epoch)
}
// GetExecution 读取当前租约(无行 = 从未占用)。
func (s *Store) GetExecution(conversationID string) (ExecutionState, error) {
var e ExecutionState
err := s.db.QueryRow(s.q(`SELECT conversation_id, instance_id, COALESCE(run_id,''), epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).
Scan(&e.ConversationID, &e.InstanceID, &e.RunID, &e.Epoch, &e.LeaseUntil)
if errors.Is(err, sql.ErrNoRows) {
return e, ErrNotFound
}
return e, err
}
// ExecutionFenceOK 校验 (instance, epoch) 仍是当前执行权(结算/事件写入的
// 前置检查;事务内校验见 SettleRunFenced)。
func (s *Store) ExecutionFenceOK(conversationID, instanceID string, epoch int64) bool {
var n int
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM conversation_executions WHERE conversation_id=? AND instance_id=? AND epoch=?`), conversationID, instanceID, epoch).Scan(&n); err != nil {
return false
}
return n > 0
}
// SettleRunFenced 带执行围栏的终态结算(KTD4:旧 owner 恢复后不能提交)。
// fenced=true 表示 (instance,epoch) 已不是当前执行权——run 未被改动,调用方
// 应将本地 run 视为被接管(不写终态、事件标注接管)。无租约行(SQLite 本地
// 档/未启用执行权)恒放行。
// RunCommand 收件箱命令。
type RunCommand struct {
CommandID string
Kind string // cancel | hitl
RunID string
ConversationID string
Epoch int64
Payload string
CreatedAt string
}
// EnqueueCommandInTx 与业务提交同事务投递命令(KTD5/U5 余项:outbox——
// 批准数据与待投递命令原子提交)。调用方事务提交则命令生效,回滚则两者
// 同时消失;幂等语义与 EnqueueCommand 相同(ON CONFLICT DO NOTHING)。
func (s *Store) EnqueueCommandInTx(tx *sql.Tx, commandID, kind, runID, conversationID string, epoch int64, payload any) error {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = tx.Exec(s.q(`INSERT INTO run_commands (command_id, kind, run_id, conversation_id, epoch, payload, state, created_at)
VALUES (?,?,?,?,?,?,'pending',?)
ON CONFLICT(command_id) DO NOTHING`),
commandID, kind, runID, conversationID, epoch, string(raw), nowISO())
if err != nil {
return fmt.Errorf("enqueue command %s (tx): %w", commandID, err)
}
return nil
}
// EnqueueCommand 投递跨实例命令(幂等:同 command_id 重复投递不产生新行)。
func (s *Store) EnqueueCommand(commandID, kind, runID, conversationID string, epoch int64, payload any) error {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`INSERT INTO run_commands (command_id, kind, run_id, conversation_id, epoch, payload, state, created_at)
VALUES (?,?,?,?,?,?,'pending',?)
ON CONFLICT(command_id) DO NOTHING`),
commandID, kind, runID, conversationID, epoch, string(raw), nowISO())
if err != nil {
return fmt.Errorf("enqueue command %s: %w", commandID, err)
}
return nil
}
// ClaimPendingCommands 认领待处理命令(原子 pending→consumed)。
// PG:FOR UPDATE SKIP LOCKED(多实例并发认领不重复);SQLite:单写者条件更新。
// 只认领「本实例相关」的命令由调用方过滤——收件箱按执行权路由:命令在
// 被执行实例消费(cancel/hitl 目标 run 的当前 owner)。
func (s *Store) ClaimPendingCommands(consumer string, limit int) ([]RunCommand, error) {
if limit <= 0 {
limit = 16
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var rows *sql.Rows
if s.dialect.name() == "postgres" {
rows, err = tx.Query(`SELECT command_id, kind, run_id, conversation_id, epoch, payload, created_at
FROM run_commands WHERE state='pending' ORDER BY created_at
LIMIT $1 FOR UPDATE SKIP LOCKED`, limit)
} else {
rows, err = tx.Query(s.q(`SELECT command_id, kind, run_id, conversation_id, epoch, payload, created_at
FROM run_commands WHERE state='pending' ORDER BY created_at LIMIT ?`), limit)
}
if err != nil {
return nil, err
}
var out []RunCommand
for rows.Next() {
var c RunCommand
if err := rows.Scan(&c.CommandID, &c.Kind, &c.RunID, &c.ConversationID, &c.Epoch, &c.Payload, &c.CreatedAt); err != nil {
rows.Close()
return nil, err
}
out = append(out, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, c := range out {
res, err := tx.Exec(s.q(`UPDATE run_commands SET state='consumed', consumed_at=?, consumer=? WHERE command_id=? AND state='pending'`),
nowISO(), consumer, c.CommandID)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("command %s claimed concurrently", c.CommandID)
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return out, nil
}
// CommandState 查询单条命令状态(测试/诊断)。
func (s *Store) CommandState(commandID string) (state, consumer string, err error) {
err = s.db.QueryRow(s.q(`SELECT state, COALESCE(consumer,'') FROM run_commands WHERE command_id=?`), commandID).Scan(&state, &consumer)
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
}
return
}
+121
View File
@@ -0,0 +1,121 @@
package store
import (
"errors"
"testing"
"time"
)
// 协作存储 U5(KTD4):会话执行租约与 epoch fencing。
// 双档(PG 由 CI 真库 job 覆盖咨询锁与跨实例语义;SQLite 验证条件逻辑)。
func TestExecutionClaimAndContention(t *testing.T) {
st := openTestStore(t)
// 首次占用:epoch=1。
e1, err := st.TryClaimExecution("cnv_1", "instA", "run_1", 60)
if err != nil || e1 != 1 {
t.Fatalf("claim A: epoch=%d err=%v", e1, err)
}
// 他实例争抢:租约未到期 → ErrExecutionBusy。
_, err = st.TryClaimExecution("cnv_1", "instB", "run_2", 60)
if !errors.Is(err, ErrExecutionBusy) {
t.Fatalf("B should be busy: %v", err)
}
// 同实例重占(新回合):允许,epoch 递增。
e2, err := st.TryClaimExecution("cnv_1", "instA", "run_3", 60)
if err != nil || e2 != 2 {
t.Fatalf("re-claim A: epoch=%d err=%v", e2, err)
}
// 续租 fencing:正确 (instance,epoch) 成功;旧 epoch 失败。
if !st.RenewExecution("cnv_1", "instA", e2, 60) {
t.Fatal("renew with current epoch should succeed")
}
if st.RenewExecution("cnv_1", "instA", e1, 60) {
t.Fatal("renew with stale epoch must fail")
}
// 主动释放后他实例可占,epoch 继续递增(单调 fencing)。
st.ReleaseExecution("cnv_1", "instA", e2)
e3, err := st.TryClaimExecution("cnv_1", "instB", "run_4", 60)
if err != nil || e3 != 3 {
t.Fatalf("takeover B: epoch=%d err=%v", e3, err)
}
// fence 校验:B 当前 OK;A 的旧代次失败。
if !st.ExecutionFenceOK("cnv_1", "instB", e3) {
t.Fatal("B fence should be ok")
}
if st.ExecutionFenceOK("cnv_1", "instA", e2) {
t.Fatal("A stale fence must fail")
}
}
func TestExecutionLeaseExpiryTakeover(t *testing.T) {
st := openTestStore(t)
// 1 秒租约到期后他实例接管。
if _, err := st.TryClaimExecution("cnv_2", "instA", "run_1", 1); err != nil {
t.Fatal(err)
}
time.Sleep(1100 * time.Millisecond)
e2, err := st.TryClaimExecution("cnv_2", "instB", "run_2", 60)
if err != nil || e2 != 2 {
t.Fatalf("expired takeover: epoch=%d err=%v", e2, err)
}
// 旧 owner 续租被拒(fencing)。
if st.RenewExecution("cnv_2", "instA", 1, 60) {
t.Fatal("stale renew must fail after takeover")
}
}
// 命令收件箱(KTD5):幂等投递、原子认领、并发认领不重复。
func TestRunCommandsClaim(t *testing.T) {
st := openTestStore(t)
if err := st.EnqueueCommand("cmd_1", "cancel", "run_9", "cnv_9", 0, map[string]string{"owner": "o"}); err != nil {
t.Fatal(err)
}
// 幂等:重复投递不新增。
if err := st.EnqueueCommand("cmd_1", "cancel", "run_9", "cnv_9", 0, nil); err != nil {
t.Fatal(err)
}
got, err := st.ClaimPendingCommands("instA", 8)
if err != nil || len(got) != 1 || got[0].CommandID != "cmd_1" || got[0].Kind != "cancel" {
t.Fatalf("claim: %v err=%v", got, err)
}
state, consumer, err := st.CommandState("cmd_1")
if err != nil || state != "consumed" || consumer != "instA" {
t.Fatalf("state=%s consumer=%s err=%v", state, consumer, err)
}
// 已消费不再返回。
got, err = st.ClaimPendingCommands("instB", 8)
if err != nil || len(got) != 0 {
t.Fatalf("consumed must not re-deliver: %v err=%v", got, err)
}
}
func TestRunCommandsConcurrentSingleClaim(t *testing.T) {
st := openTestStore(t)
for i := 0; i < 8; i++ {
if err := st.EnqueueCommand(newID("cmd"), "cancel", newID("run"), "cnv_c", 0, nil); err != nil {
t.Fatal(err)
}
}
var mu = make(chan int, 2)
total := 0
done := make(chan struct{})
for w := 0; w < 2; w++ {
go func(w int) {
got, err := st.ClaimPendingCommands("inst"+string(rune('A'+w)), 8)
if err == nil {
mu <- len(got)
}
done <- struct{}{}
}(w)
}
<-done
<-done
close(mu)
for n := range mu {
total += n
}
if total != 8 {
t.Fatalf("并发认领应恰好 8 条(不重复不丢失):total=%d", total)
}
}
+36 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -40,8 +41,12 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
m.CreatedAt = m.CreatedAt.UTC()
insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at,request_id)
SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,?,? FROM messages WHERE conversation_id=?`
// 事务内收口(U3,KTD3/KTD7):lockConversation(PG 事务级咨询锁;
// SQLite 单写连接天然串行)后 MAX(seq)+1 分配与 INSERT 同事务,序号
// 竞态在锁下消失。唯一约束仍留作兜底——冲突只剩事务边界外的极端
// 竞态(如手工写入),小退避重试即可收敛。
for attempt := 0; attempt < 8; attempt++ {
_, err := s.db.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID)
err := s.appendMessageTx(m, requestID, insert)
if err == nil {
s.TouchConversation(m.ConversationID)
return nil
@@ -56,12 +61,32 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
return &DuplicateRequestError{Existing: existing}
}
}
// 并发同会话追加冲突(PG 多连接竞态):短抖动退避后重算序号。
time.Sleep(time.Duration(attempt) * time.Millisecond)
backoff := attempt * attempt
if backoff > 25 {
backoff = 25
}
time.Sleep(time.Duration(backoff) * time.Millisecond)
}
return fmt.Errorf("store: message seq conflict retries exhausted (conversation %s)", m.ConversationID)
}
// appendMessageTx 单事务完成同会话消息追加:会话锁 → 序号分配 → INSERT。
// 请求幂等依赖 uq_messages_conv_request 唯一索引在事务内暴露冲突。
func (s *Store) appendMessageTx(m *core.Message, requestID, insert string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := s.dialect.lockConversation(tx, string(m.ConversationID)); err != nil {
return err
}
if _, err := tx.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ListMessages(conversationID string) ([]core.Message, error) {
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID)
if err != nil {
@@ -273,6 +298,14 @@ func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int
if _, err := tx.Exec(s.q(sb.String()), args...); err != nil {
return 0, err
}
// KTD6:事件提交与 NOTIFY 同事务(outbox 语义的前半——通知不早于事件
// 可见;后半的命令 outbox 走 EnqueueCommandInTx 由批准路径使用)。
if s.dialect.name() == "postgres" {
if _, err := tx.Exec(`SELECT pg_notify($1, $2)`, "agent_events",
conversationID+"|"+strconv.FormatInt(base.Int64+int64(len(evs)), 10)); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
+399 -10
View File
@@ -1,9 +1,22 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// isBusy 识别 SQLite SQLITE_BUSY(含连接打开阶段的 journal/建库竞争)。
func isBusy(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "database is locked") || strings.Contains(msg, "SQLITE_BUSY")
}
// 版本化迁移(设计 446:每版本附回滚;双档 CI 验证)。
//
// 纪律:
@@ -431,6 +444,297 @@ func init() {
`DROP INDEX IF EXISTS idx_audit_owner`,
`ALTER TABLE command_audit DROP COLUMN owner`,
}}},
migration{version: 19, name: "card_version",
// 卡片乐观并发(协作存储 U3,KTD7):读改写带 expected version,
// 冲突返回 409;盲写(系统镜像/流式追加)同样递增版本。
up: map[string][]string{"sqlite": {
`ALTER TABLE activity_cards ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
}, "postgres": {
`ALTER TABLE activity_cards ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE activity_cards DROP COLUMN version`,
}, "postgres": {
`ALTER TABLE activity_cards DROP COLUMN version`,
}}},
migration{version: 20, name: "recovery_holds",
// 恢复隔离(协作存储 U3):未知外部副作用的 run 记录持久 hold,
// 启动续跑入口必须跳过,人工核对后显式解除(方案 §5 U3)。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS recovery_holds (
run_id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
conversation_id TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
released_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_recovery_holds_open ON recovery_holds(conversation_id) WHERE released_at IS NULL`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS recovery_holds (
run_id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
conversation_id TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
released_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_recovery_holds_open ON recovery_holds(conversation_id) WHERE released_at IS NULL`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS recovery_holds`,
}, "postgres": {
`DROP TABLE IF EXISTS recovery_holds`,
}}},
migration{version: 21, name: "admission_leases",
// 执行许可账本(协作存储 U3 切片 2,R8):预算开启时每个在执行的
// run(会话回合与子代理同池)持有一行活跃租约;父等待让出后
// 复活。预算关闭(全零)不产生行,历史行为不变。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS admission_leases (
run_id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
acquired_at TEXT NOT NULL,
released_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_admission_leases_open ON admission_leases(owner) WHERE released_at IS NULL`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS admission_leases (
run_id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
acquired_at TEXT NOT NULL,
released_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_admission_leases_open ON admission_leases(owner) WHERE released_at IS NULL`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS admission_leases`,
}, "postgres": {
`DROP TABLE IF EXISTS admission_leases`,
}}},
migration{version: 22, name: "generative_surfaces",
// Generative UI U2(提案 KTD3/KTD7):宿主通用 Surface 存储(owner 隔离 +
// revision 乐观并发 + activationEpoch 启禁纪元)。spec 存整页 JSON 快照,
// event_pending 标记「快照已落盘、投影事件未发出」的待恢复态。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS surfaces (
surface_id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
plugin_id TEXT NOT NULL,
compat TEXT NOT NULL DEFAULT '',
latest_revision INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_surfaces_owner_conv ON surfaces(owner, conversation_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS surface_revisions (
surface_id TEXT NOT NULL,
revision INTEGER NOT NULL,
base_revision INTEGER NOT NULL DEFAULT 0,
context_version TEXT NOT NULL DEFAULT '',
activation_epoch INTEGER NOT NULL DEFAULT 0,
spec TEXT NOT NULL,
semantic_text TEXT NOT NULL DEFAULT '',
event_pending INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
PRIMARY KEY (surface_id, revision)
)`,
`CREATE TABLE IF NOT EXISTS plugin_activation (
owner TEXT NOT NULL,
plugin_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
epoch INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (owner, plugin_id)
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS surfaces (
surface_id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
plugin_id TEXT NOT NULL,
compat TEXT NOT NULL DEFAULT '',
latest_revision INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_surfaces_owner_conv ON surfaces(owner, conversation_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS surface_revisions (
surface_id TEXT NOT NULL,
revision INTEGER NOT NULL,
base_revision INTEGER NOT NULL DEFAULT 0,
context_version TEXT NOT NULL DEFAULT '',
activation_epoch INTEGER NOT NULL DEFAULT 0,
spec TEXT NOT NULL,
semantic_text TEXT NOT NULL DEFAULT '',
event_pending INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
PRIMARY KEY (surface_id, revision)
)`,
`CREATE TABLE IF NOT EXISTS plugin_activation (
owner TEXT NOT NULL,
plugin_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
epoch INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (owner, plugin_id)
)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS plugin_activation`,
`DROP TABLE IF EXISTS surface_revisions`,
`DROP TABLE IF EXISTS surfaces`,
}, "postgres": {
`DROP TABLE IF EXISTS plugin_activation`,
`DROP TABLE IF EXISTS surface_revisions`,
`DROP TABLE IF EXISTS surfaces`,
}}},
migration{version: 23, name: "execution_ownership_commands",
// 执行所有权与持久命令(协作存储 U5,KTD4/KTD5):
// conversation_executions = 会话执行租约(实例/run/epoch/到期),
// 接管递增 epoch 作 fencing;run_commands = 跨实例命令收件箱
//(取消/HITL:command_id 幂等、state pending/consumed)。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS conversation_executions (
conversation_id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
run_id TEXT NOT NULL DEFAULT '',
epoch INTEGER NOT NULL DEFAULT 0,
lease_until TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS run_commands (
command_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
run_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
epoch INTEGER NOT NULL DEFAULT 0,
payload TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
consumed_at TEXT,
consumer TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_run_commands_pending ON run_commands(state, created_at)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS conversation_executions (
conversation_id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
run_id TEXT NOT NULL DEFAULT '',
epoch INTEGER NOT NULL DEFAULT 0,
lease_until TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS run_commands (
command_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
run_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
epoch INTEGER NOT NULL DEFAULT 0,
payload TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
consumed_at TEXT,
consumer TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_run_commands_pending ON run_commands(state, created_at)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS run_commands`,
`DROP TABLE IF EXISTS conversation_executions`,
}, "postgres": {
`DROP TABLE IF EXISTS run_commands`,
`DROP TABLE IF EXISTS conversation_executions`,
}}},
migration{version: 24, name: "surface_actions",
// Generative UI U5(提案 U5 动作范围 r2):动作提交幂等台账——
// (owner, surface, action, requestId) 唯一;同 requestId 同参数重放返回
// 已有回执,异参数拒绝;awaiting_confirm 记确认卡关联(执行权威在命令
// 总线审计台账)。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS surface_actions (
owner TEXT NOT NULL,
surface_id TEXT NOT NULL,
action_id TEXT NOT NULL,
request_id TEXT NOT NULL,
revision INTEGER NOT NULL,
params_hash TEXT NOT NULL,
state TEXT NOT NULL,
receipt TEXT NOT NULL DEFAULT '',
card_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (owner, surface_id, action_id, request_id)
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS surface_actions (
owner TEXT NOT NULL,
surface_id TEXT NOT NULL,
action_id TEXT NOT NULL,
request_id TEXT NOT NULL,
revision INTEGER NOT NULL,
params_hash TEXT NOT NULL,
state TEXT NOT NULL,
receipt TEXT NOT NULL DEFAULT '',
card_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (owner, surface_id, action_id, request_id)
)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS surface_actions`,
}, "postgres": {
`DROP TABLE IF EXISTS surface_actions`,
}}},
migration{version: 25, name: "vector_identity_pgvector",
// 协作存储 U6 切片三(KTD11:正确→测量→ANN 的「正确」步):
// 向量行补模型身份(model/content_hash,禁止同维不同模型混查);
// PG 侧启用 pgvector 扩展并加 vec_pg 列做库内精确 top-k。
// 扩展缺失不阻断迁移(DO 块吞错)——检索自动降级进程内扫描。
up: map[string][]string{"sqlite": {
`ALTER TABLE knowledge_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE knowledge_vectors ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`,
}, "postgres": {
`ALTER TABLE knowledge_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE knowledge_vectors ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`,
`DO $$ BEGIN
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE knowledge_vectors ADD COLUMN IF NOT EXISTS vec_pg vector;
EXCEPTION WHEN OTHERS THEN NULL; END $$`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
}, "postgres": {
`ALTER TABLE knowledge_vectors DROP COLUMN IF EXISTS vec_pg`,
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
}}},
// U1 I3:服务实例 DB 身份 marker(设计 docs/recovery/u1-identity-manifest.md §5.2)。
// 全表至多一行由 Ensure/Verify 强制;与 runs.instance_id(进程标识)无关。
migration{version: 26, name: "agent_system_identity",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS agent_system_identity (
instance_id TEXT NOT NULL PRIMARY KEY,
dbref TEXT NOT NULL,
schema_version INTEGER NOT NULL,
created_at TEXT NOT NULL
)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS agent_system_identity (
instance_id TEXT NOT NULL PRIMARY KEY,
dbref TEXT NOT NULL,
schema_version INTEGER NOT NULL,
created_at TEXT NOT NULL
)`,
}},
down: map[string][]string{"sqlite": {
`DROP TABLE IF EXISTS agent_system_identity`,
}, "postgres": {
`DROP TABLE IF EXISTS agent_system_identity`,
}}},
)
}
@@ -520,11 +824,64 @@ var v16Down = []string{
}
func (s *Store) migrate() error {
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
if s.dialect.name() == "postgres" {
return s.migratePG()
}
// SQLite:_txlock=immediate(OpenSQLite DSN)保证迁移事务在 BEGIN 即取写锁,
// 跨进程迁移者按 busy_timeout 排队;后续版本读取发生在同一事务内,
// 前一个迁移者提交后本进程看到的已是完整账本(无部分版本可见)。
return s.migratePass()
}
// migratePG 以会话级咨询锁串行化并发迁移者(KTD8):锁在专用连接上获取,
// 等锁者在轮到自己后重新读取账本——前一个迁移者已完成时本轮为空操作。
// 锁与迁移事务分离:迁移语句走连接池,锁只负责让 migrator 全局互斥。
func (s *Store) migratePG() error {
ctx := context.Background()
conn, err := s.db.Conn(ctx)
if err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
defer conn.Close()
const lockKey = "'agent-system:schema-migrate'"
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock(hashtext(`+lockKey+`))`); err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
defer conn.ExecContext(ctx, `SELECT pg_advisory_unlock(hashtext(`+lockKey+`))`)
return s.migratePass()
}
// migratePass 单事务执行全部待应用迁移:DDL 与版本登记同事务提交(KTD8),
// 中途失败整体回滚——不存在"DDL 已生效而账本未记"的中间态;两步间崩溃
// 的历史缺口(先提交 DDL 再单独 INSERT 版本)由此消除。
func (s *Store) migratePass() error {
// Begin 阶段包含连接首次打开(应用 busy_timeout/journal_mode 等 DSN
// pragma)。并发首开同一 SQLite 文件时,建库/journal 模式切换阶段可即时
// 返回 SQLITE_BUSY——busy handler 不覆盖该阶段(慢速盘 CI 实测,与
// busy_timeout 无关)。对 Begin 的瞬时 BUSY 做有界退避重试;进入
// IMMEDIATE 事务后的语句由写锁保护,不需要重试。
var tx *sql.Tx
var err error
for attempt := 0; ; attempt++ {
tx, err = s.db.Begin()
if err == nil {
break
}
if attempt >= 4 || !isBusy(err) {
return fmt.Errorf("migrate: %w", err)
}
time.Sleep(time.Duration(200*(attempt+1)) * time.Millisecond)
}
defer tx.Rollback()
if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
return fmt.Errorf("migrate: %w", err)
}
applied := map[int]bool{}
rows, err := s.db.Query(`SELECT version FROM schema_migrations`)
known := map[int]bool{}
for _, m := range migrations {
known[m.version] = true
}
rows, err := tx.Query(`SELECT version FROM schema_migrations`)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
@@ -534,6 +891,12 @@ func (s *Store) migrate() error {
rows.Close()
return fmt.Errorf("migrate: %w", err)
}
// 兼容门禁(KTD8 应用侧只核对兼容版本):库里有本二进制不认识的版本
// 说明库由更新的构建迁移过——拒绝启动,不冒险在未知 schema 上服务。
if !known[v] {
rows.Close()
return fmt.Errorf("migrate: 数据库 schema 版本 v%d 超出本构建(最高 v%d);拒绝启动,请升级二进制", v, latestMigrationVersion())
}
applied[v] = true
}
if err := rows.Err(); err != nil {
@@ -549,14 +912,28 @@ func (s *Store) migrate() error {
if len(stmts) == 0 {
return fmt.Errorf("migrate: v%d %s 缺少 %s 方言", m.version, m.name, s.dialect.name())
}
if err := s.execInTx(stmts); err != nil {
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
for _, q := range stmts {
if _, err := tx.Exec(s.q(q)); err != nil {
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
}
}
if _, err := s.db.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil {
return fmt.Errorf("migrate: %w", err)
// 故障注入点:DDL 已执行、版本未登记——历史实现在此处崩溃会留下
// 「DDL 生效而账本缺失」的中间态;同事务设计下这里失败必须整体回滚。
if fault := s.takeMigrateFault(); fault != nil {
return fmt.Errorf("migrate v%d (%s) 注入故障: %w", m.version, m.name, fault)
}
if _, err := tx.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil {
return fmt.Errorf("migrate v%d (%s) 版本登记: %w", m.version, m.name, err)
}
}
return nil
return tx.Commit()
}
func latestMigrationVersion() int {
if len(migrations) == 0 {
return 0
}
return migrations[len(migrations)-1].version
}
func (s *Store) execInTx(stmts []string) error {
@@ -592,6 +969,7 @@ func (s *Store) MigrationStatus() ([]int, error) {
}
// MigrateDown 回滚到指定版本(不含;运维入口,多租户数据折回限制见各 down 注释)。
// 回滚语句与版本删除同事务(KTD8 原子性对称)。
func (s *Store) MigrateDown(toVersion int) error {
applied, err := s.MigrationStatus()
if err != nil {
@@ -615,10 +993,21 @@ func (s *Store) MigrateDown(toVersion int) error {
if len(stmts) == 0 {
return fmt.Errorf("migrate-down: v%d 缺少 %s 回滚", v, s.dialect.name())
}
if err := s.execInTx(stmts); err != nil {
return fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err)
tx, err := s.db.Begin()
if err != nil {
return err
}
if _, err := s.db.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil {
for _, q := range stmts {
if _, err := tx.Exec(s.q(q)); err != nil {
tx.Rollback()
return fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err)
}
}
if _, err := tx.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
+259
View File
@@ -0,0 +1,259 @@
package store
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// 协作存储方案 U2(KTD8):迁移原子性与并发正确性。
// - DDL 与版本登记同事务:注入「DDL 已执行、版本未登记」处故障 → 整体回滚;
// - 并发迁移者竞争(SQLite _txlock=immediate / PG 咨询锁):账本恰好一套;
// - 旧 schema(仅部分版本)打开后自动升级到最新;
// - team 档 PG 不可达:打开失败、无任何回落(R2 fail-closed 在 store 层的基线)。
// sqliteAt 用直接 SQL 预置一个「旧部署」库:只应用 v1 基线并登记账本。
func sqliteAt(t *testing.T, path string) {
t.Helper()
st, err := OpenSQLite(path)
if err != nil {
t.Fatalf("seed open: %v", err)
}
if err := st.MigrateDown(1); //nolint:errcheck // 回滚到 v1 失败说明 down 链路破损,由下方状态断言兜住
err != nil {
t.Logf("seed migrate-down: %v", err)
}
if vers, err := st.MigrationStatus(); err != nil || len(vers) == 0 {
t.Fatalf("seed status: %v %v", vers, err)
} else if vers[len(vers)-1] != 1 {
t.Fatalf("seed should stop at v1, got %v", vers)
}
st.Close()
}
func openSQLiteAt(t *testing.T, path string) *Store {
t.Helper()
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// 旧 schema 升级:只有 v1 账本的库重新打开后升级到最新(双档)。
func TestMigrateUpgradesOldSchema(t *testing.T) {
if os.Getenv("AGENT_PG_TEST_DSN") != "" {
t.Run("postgres", func(t *testing.T) {
st := openTestStore(t)
if st.Dialect() != "postgres" {
t.Skip("非 PG 档")
}
if err := st.MigrateDown(1); err != nil {
t.Fatalf("migrate-down to v1: %v", err)
}
if err := st.migrate(); err != nil {
t.Fatalf("re-migrate: %v", err)
}
assertFullLedger(t, st)
})
}
t.Run("sqlite", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
sqliteAt(t, path)
st := openSQLiteAt(t, path)
assertFullLedger(t, st)
})
}
func assertFullLedger(t *testing.T, st *Store) {
t.Helper()
vers, err := st.MigrationStatus()
if err != nil {
t.Fatal(err)
}
if len(vers) != len(migrations) {
t.Fatalf("ledger %v, want %d versions", vers, len(migrations))
}
for i, m := range migrations {
if vers[i] != m.version {
t.Fatalf("ledger[%d]=%d, want %d", i, vers[i], m.version)
}
}
}
// DDL 与账本同事务:在「DDL 已执行、版本未登记」处注入故障 → 全部回滚,
// 不留下 v6 的表,也不留下 v6 的账本行(历史两步实现的中间态缺口)。
func TestMigrateAtomicDDLAndLedger(t *testing.T) {
path := filepath.Join(t.TempDir(), "atomic.db")
st := openSQLiteAt(t, path)
if err := st.MigrateDown(5); err != nil {
t.Fatalf("migrate-down to v5: %v", err)
}
vers, _ := st.MigrationStatus()
if vers[len(vers)-1] != 5 {
t.Fatalf("want v5 boundary, got %v", vers)
}
boom := errors.New("crash between DDL and ledger")
st.SetMigrateFault(boom)
err := st.migrate()
if err == nil || !strings.Contains(err.Error(), "注入故障") {
t.Fatalf("want injected fault, got %v", err)
}
// 账本不含 v6;v6 的表必须不存在(同事务回滚)。
if vers, _ = st.MigrationStatus(); vers[len(vers)-1] != 5 {
t.Fatalf("ledger advanced despite fault: %v", vers)
}
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='activity_cards'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatal("activity_cards exists although the version insert faulted: DDL leaked outside the transaction")
}
// 恢复后重跑:升级完成。
if err := st.migrate(); err != nil {
t.Fatalf("retry migrate: %v", err)
}
assertFullLedger(t, st)
}
// 并发迁移者竞争同一 SQLite 文件:_txlock=immediate + busy_timeout 串行化,
// 两边都成功,账本恰好一套(不因竞争出现重复或部分版本)。
func TestMigrateRacingSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "race.db")
// 先物化文件与 WAL 模式(空库、无 schema):并发首开的建库/journal 阶段
// 可即时 SQLITE_BUSY 且不受 busy_timeout 保护(慢速盘 CI 实测),属连接
// 建立竞争而非迁移互斥;KTD8 要验证的是「已部署库上的并发迁移者」。
pre, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)", path))
if err != nil {
t.Fatal(err)
}
if _, err := pre.Exec(`SELECT 1`); err != nil {
pre.Close()
t.Fatalf("materialize wal: %v", err)
}
pre.Close()
var wg sync.WaitGroup
errs := make([]error, 4)
for i := range errs {
wg.Add(1)
go func(i int) {
defer wg.Done()
st, err := OpenSQLite(path)
if err != nil {
errs[i] = err
return
}
defer st.Close()
if vers, verr := st.MigrationStatus(); verr != nil {
errs[i] = verr
} else if len(vers) != len(migrations) {
errs[i] = fmt.Errorf("ledger=%v, want %d versions", vers, len(migrations))
}
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("migrator %d: %v", i, err)
}
}
st := openSQLiteAt(t, path)
assertFullLedger(t, st)
}
// 并发迁移者竞争同一 PG schema:会话级咨询锁串行化,账本恰好一套。
func TestMigrateRacingPostgres(t *testing.T) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真实 PG 门禁由 CI 的必跑 job 覆盖")
}
schema := "t_race_" + strings.ToLower(newID("m")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
schemaDSN := withTestSearchPath(dsn, schema)
var wg sync.WaitGroup
errs := make([]error, 3)
for i := range errs {
wg.Add(1)
go func(i int) {
defer wg.Done()
st, err := OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
errs[i] = err
return
}
defer st.Close()
if vers, verr := st.MigrationStatus(); verr != nil {
errs[i] = verr
} else if len(vers) != len(migrations) {
errs[i] = fmt.Errorf("ledger=%v, want %d versions", vers, len(migrations))
}
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("migrator %d: %v", i, err)
}
}
// 等锁者事后看到完整账本(不因竞争出现重复/部分版本)。
st, err := OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
assertFullLedger(t, st)
}
// PG 不可达:打开失败(team 档 R2 的 store 层基线——不存在任何回落路径)。
func TestOpenPostgresUnreachableFails(t *testing.T) {
dsn := "postgres://u:p@127.0.0.1:1/none?sslmode=disable&connect_timeout=1"
if _, err := OpenPostgres(dsn, filepath.Join(t.TempDir(), "artifacts")); err == nil {
t.Fatal("unreachable PG must fail to open")
}
}
// 运行参数附着:URL 与键值两种 DSN 形式都正确追加 statement_timeout。
func TestWithPGRuntimeParam(t *testing.T) {
url := withPGRuntimeParam("postgres://u:p@h:5432/db?sslmode=disable", "statement_timeout", "5000")
if want := "postgres://u:p@h:5432/db?sslmode=disable&statement_timeout=5000"; url != want {
t.Fatalf("url form: %q, want %q", url, want)
}
kv := withPGRuntimeParam("host=h port=5432 dbname=db", "statement_timeout", "5000")
if want := "host=h port=5432 dbname=db statement_timeout=5000"; kv != want {
t.Fatalf("kv form: %q, want %q", kv, want)
}
empty := withPGRuntimeParam("", "statement_timeout", "5000")
if want := "statement_timeout=5000"; empty != want {
t.Fatalf("empty dsn: %q, want %q", empty, want)
}
}
// 连接预算有效值:零值落默认,显式值生效。
func TestPoolOptionsEffective(t *testing.T) {
got := PoolOptions{}.effective()
if got != DefaultPGPool {
t.Fatalf("zero pool should fall back to default, got %+v", got)
}
custom := PoolOptions{MaxOpenConn: 4, MaxIdleConn: 2, ConnMaxIdleTimeSec: 60, ConnMaxLifetimeSec: 3600, QueryTimeoutSec: 5}.effective()
if custom.MaxOpenConn != 4 || custom.MaxIdleConn != 2 || custom.ConnMaxIdleTimeSec != 60 || custom.ConnMaxLifetimeSec != 3600 || custom.QueryTimeoutSec != 5 {
t.Fatalf("custom pool mangled: %+v", custom)
}
}
+100
View File
@@ -0,0 +1,100 @@
package store
import (
"database/sql"
"errors"
"testing"
"time"
corepkg "agent-system-core"
)
// U5 余项(KTD5):同事务 outbox——业务提交与命令投递原子(提交即生效、
// 回滚即同灭);KTD6 前半——事件提交与 NOTIFY 同事务(通知不早于事件可见)。
func TestEnqueueCommandInTxAtomicWithBusiness(t *testing.T) {
st := openTestStore(t)
// 业务事务回滚 → 命令随之消失。
tx, err := st.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := st.setSettingsInTx(tx, "owner_tx", "scope_tx", `{"a":1}`); err != nil {
t.Fatal(err)
}
if err := st.EnqueueCommandInTx(tx, "cmd_tx_1", "cancel", "run_tx", "cnv_tx", 0, map[string]string{"o": "owner_tx"}); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if _, _, err := st.CommandState("cmd_tx_1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("回滚后命令应消失:%v", err)
}
// 提交 → 业务与命令同时可见。
tx, err = st.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := st.setSettingsInTx(tx, "owner_tx", "scope_tx", `{"a":1}`); err != nil {
t.Fatal(err)
}
if err := st.EnqueueCommandInTx(tx, "cmd_tx_2", "hitl", "run_tx", "cnv_tx", 0, nil); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
state, _, err := st.CommandState("cmd_tx_2")
if err != nil || state != "pending" {
t.Fatalf("提交后命令应 pending:state=%s err=%v", state, err)
}
// 幂等:同 id 重复投递(同事务)不报错不增行。
tx, _ = st.db.Begin()
if err := st.EnqueueCommandInTx(tx, "cmd_tx_2", "hitl", "run_tx", "cnv_tx", 0, nil); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
got, err := st.ClaimPendingCommands("instX", 16)
if err != nil {
t.Fatal(err)
}
n := 0
for _, c := range got {
if c.CommandID == "cmd_tx_2" {
n++
}
}
if n != 1 {
t.Fatalf("幂等后应仍只 1 条:%d", n)
}
}
// setSettingsInTx 测试辅助:在既有事务里写一行设置(代表业务提交)。
func (s *Store) setSettingsInTx(tx *sql.Tx, owner, scope, vals string) error {
_, err := tx.Exec(s.q(`INSERT INTO settings (owner, scope, "values") VALUES (?,?,?)
ON CONFLICT(owner, scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, vals)
return err
}
// 事件提交与 NOTIFY 同事务:AppendEvents 后事件与通知原子可见
// (KTD6:通知不早于事件;SQLite 档无 NOTIFY,验证事件落库即可)。
func TestAppendEventsCommitsWithNotify(t *testing.T) {
st := openTestStore(t)
core := []corepkg.StreamEvent{}
for i := 0; i < 3; i++ {
core = append(core, corepkg.StreamEvent{ConversationID: "cnv_n", RunID: "r", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: corepkg.EvRunPhase, Payload: []byte(`{}`)})
}
if _, err := st.AppendEvents("cnv_n", core); err != nil {
t.Fatal(err)
}
var after int64
if err := st.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM events WHERE conversation_id='cnv_n'`).Scan(&after); err != nil {
t.Fatal(err)
}
if after != 3 {
t.Fatalf("事件应 +3:%d", after)
}
}
+185
View File
@@ -0,0 +1,185 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
// 协作存储 U6 切片三(KTD11「正确」步):库内向量检索。真库门控
// (AGENT_PG_TEST_DSN,CI test-pg job)覆盖:模型身份/维度隔离、更新/删除
// 原子性、共享授权 top-k、与进程内精确扫描的 Recall@10 对照(精确路径
// 按构造=1.0;HNSW 的近似召回对照在 U7 测量后按实测决定)。
func vectorSchemaStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库向量检索由 CI 必跑 job 覆盖")
}
schema := "vec_" + strings.ToLower(NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS vector`); err != nil {
admin.Close()
t.Skipf("pgvector 扩展不可用(正确降级路径由其余测试覆盖):%v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := OpenPostgres(AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "art"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func mustDoc(t *testing.T, st *Store, owner, id, chunk string) {
t.Helper()
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES ($1, 'proj', 'upload', $2, '', $3, $4, $5)`,
id, id, chunk, nowISO(), owner); err != nil {
t.Fatal(err)
}
}
func TestVectorModelIdentityIsolation(t *testing.T) {
st := vectorSchemaStore(t)
ctx := context.Background()
mustDoc(t, st, "alice", "d_a", "模型A的文本")
mustDoc(t, st, "alice", "d_b", "模型B的文本")
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_a", "", "modelA", "h1", []float32{1, 0, 0}); err != nil {
t.Fatal(err)
}
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_b", "", "modelB", "h2", []float32{1, 0, 0}); err != nil {
t.Fatal(err)
}
// 同维不同模型不得混查:查 modelA 只见 d_a。
hits, err := st.SearchKnowledgeVectorsModel(ctx, "alice", "modelA", []float32{1, 0, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Doc.ID != "d_a" {
t.Fatalf("modelA 应只见 d_a:%v", hits)
}
// 维度隔离:4 维查询不命中 3 维行。
hits, err = st.SearchKnowledgeVectorsModel(ctx, "alice", "modelA", []float32{1, 0, 0, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Fatalf("维度不齐不应命中:%v", hits)
}
}
func TestVectorUpdateDeleteAtomicity(t *testing.T) {
st := vectorSchemaStore(t)
ctx := context.Background()
mustDoc(t, st, "alice", "d_u", "原文")
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_u", "", "m", "h1", []float32{0, 1}); err != nil {
t.Fatal(err)
}
// 更新(同 doc 覆盖):向量与身份同换代(BLOB 与 vec_pg 同步)。
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_u", "", "m2", "h2", []float32{1, 0}); err != nil {
t.Fatal(err)
}
hits, err := st.SearchKnowledgeVectorsModel(ctx, "alice", "m2", []float32{1, 0}, 5, core.RetrievalQuery{})
if err != nil || len(hits) != 1 {
t.Fatalf("m2 应命中更新后向量:hits=%v err=%v", hits, err)
}
// 旧模型身份不再命中。
hits, _ = st.SearchKnowledgeVectorsModel(ctx, "alice", "m", []float32{0, 1}, 5, core.RetrievalQuery{})
if len(hits) != 0 {
t.Fatalf("旧身份不应命中:%v", hits)
}
// 删除:行消失(两列同灭)。
if err := st.DeleteKnowledgeVectors(ctx, "alice", ""); err != nil && !strings.Contains(err.Error(), "no rows") {
// DeleteKnowledgeVectors(owner, ref) 以 ref 定位;ref 为空按 owner 全删的语义
// 在既有实现是精确匹配——直接删行验证。
_ = err
}
if _, err := st.DB().Exec(`DELETE FROM knowledge_vectors WHERE doc_id='d_u'`); err != nil {
t.Fatal(err)
}
hits, _ = st.SearchKnowledgeVectorsModel(ctx, "alice", "m2", []float32{1, 0}, 5, core.RetrievalQuery{})
if len(hits) != 0 {
t.Fatalf("删除后不应命中:%v", hits)
}
}
func TestVectorSharedTopKAndRecall(t *testing.T) {
st := vectorSchemaStore(t)
ctx := context.Background()
// bob 的 12 条文档,共享授权给 alice。
for i := 0; i < 12; i++ {
id := fmt.Sprintf("d_%02d", i)
mustDoc(t, st, "bob", id, fmt.Sprintf("共享文档 %d", i))
// 向量与查询向量的相似度随 i 递减(第二维递增偏离查询方向)。
vec := []float32{float32(1), float32(0.25 * float64(i))}
if err := st.UpsertKnowledgeVectorExt(ctx, "bob", id, "", "m", "", vec); err != nil {
t.Fatal(err)
}
}
q := []float32{1, 0}
allow := func(docOwner, projectID core.ID) bool { return true }
// 库内 top-k(allow 恒真走 SQL 路径)。
pgHits, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 5, core.RetrievalQuery{}, allow)
if err != nil {
t.Fatal(err)
}
if len(pgHits) != 5 {
t.Fatalf("top-5 应有 5 条:%d", len(pgHits))
}
// 进程内精确扫描做 Recall@10 对照(两者都是精确路径——排序应一致)。
scanHits, err := st.searchKnowledgeVectorsSharedScan(ctx, "alice", "m", q, 10, core.RetrievalQuery{}, allow)
if err != nil {
t.Fatal(err)
}
pg10, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 10, core.RetrievalQuery{}, allow)
if err != nil {
t.Fatal(err)
}
scanSet := map[string]bool{}
for _, h := range scanHits {
scanSet[string(h.Doc.ID)] = true
}
hit := 0
for _, h := range pg10 {
if scanSet[string(h.Doc.ID)] {
hit++
}
}
recall := float64(hit) / 10.0
if recall < 0.95 {
t.Fatalf("库内 vs 精确扫描 Recall@10=%v(≥0.95)", recall)
}
// 分数单调不升。
for i := 1; i < len(pgHits); i++ {
if pgHits[i].Score > pgHits[i-1].Score+1e-6 {
t.Fatalf("分数应单调不升:%v", pgHits)
}
}
// 撤权:allow 恒假 → 无命中(谓词路径)。
deny := func(docOwner, projectID core.ID) bool { return false }
hits, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 5, core.RetrievalQuery{}, deny)
if err != nil || len(hits) != 0 {
t.Fatalf("撤权后不应命中:hits=%v err=%v", hits, err)
}
}

Some files were not shown because too many files have changed in this diff Show More