Compare commits

..
102 Commits
Author SHA1 Message Date
weichen f1c9cc0644 Merge pull request 'Keep DOCX tracked-change/hyperlink text and add an exec hard switch' (#110) from feature/v020-docx-and-exec-switch into main
ci / vet-and-test (push) Successful in 43s
2026-10-09 10:39:18 +00:00
weichen 68c1a7272a Keep tracked-change and hyperlink text in DOCX and add an exec hard switch
ci / vet-and-test (pull_request) Successful in 43s
Why: The DOCX parser only read w:r runs that were direct children of w:p, so
text inside w:ins, w:hyperlink and w:fldSimple was dropped with no warning,
violating the no-silent-degradation rule. Separately, runtime.exec is a
user-writable setting, so multi-user deployments could not reliably turn the
exec primitives off.
Evidence: SELF - red tests TestParseDocxTrackedChangesAndHyperlinks and
TestExecHardDisabledOverridesSettings failed before and pass after; backend
go test ./... (32 packages) passes; a mock-mode binary started with
--disable-exec-tools hides run_shell/run_python/register_artifact from
/api/tools even after the user sets runtime.exec=true.
Impact: DOCX paragraphs are decoded from the token stream at any depth;
deleted/moved-from text, field codes and mc:Fallback are excluded and
revised documents carry an explicit warning. New Options.DisableExecTools
(SurfaceVersion 2.5.0-experimental), CLI --disable-exec-tools, config key
disableExecTools and env AGENT_SYSTEM_DISABLE_EXEC_TOOLS; zero value keeps
current behavior.
Refs: Agent-System-RDM docs/proposals/2026-10-09-V22-review-and-implementation-plan.md (G0-7, B-5)
2026-10-09 10:36:02 +00:00
weichen 399e2e53e6 Merge pull request 'Close the EXT-8 remaining blocks: vector sharing, hostapi face, citation consistency' (#109) from feature/ext8-remaining-shared-reads into main
ci / vet-and-test (push) Successful in 38s
2026-10-08 17:22:46 +00:00
weichen 92ede3b970 Close the EXT-8 remaining blocks: vector sharing, hostapi face, citation consistency
ci / vet-and-test (pull_request) Successful in 37s
Why: EXT-8 closure needs the vector channel shared (not just fulltext), the /host/v1 knowledge face honoring the authorizer, and citation-ref-to-artifact consistency pinned as an acceptance test.
Evidence: TestRetrieveSharedVectorChannel (hash embedder: authorized shared vector hit retrievable, denied yields zero), TestSharedSearchFace (hostapi switches to SharedSearch when wired), TestSharedCitationRefDownloadable (shared hit -> Ref -> artifact content download for the authorized member); go test ./... 32/32 packages ok.
Impact: store.SearchKnowledgeVectorsShared; rag.Pipeline.RetrieveShared (own + shared fulltext + shared vector, RRF-merged); server searchKnowledge prefers RetrieveShared when the retriever implements it; hostapi Server.SharedSearch wired in the composition root through the same authorizer slot. Default behavior unchanged.
Refs: EXT-8 proposal acceptance; slices 1-2.
2026-10-08 17:16:10 +00:00
weichen 46c2abbca8 Merge pull request 'Wire the EXT-11 scoped-host factory through the composition root' (#108) from feature/ext-11-scoped-host-factory into main
ci / vet-and-test (push) Successful in 39s
2026-10-08 17:08:07 +00:00
weichen bbe39b475f gofmt the ext-11 files
ci / vet-and-test (pull_request) Successful in 42s
Why: The implementation commit left two files unformatted; keep the tree gofmt-clean.
Evidence: gofmt -l now empty; targeted test still green.
Impact: Formatting only.
Refs: Follow-up to the wiring commit.
2026-10-08 17:02:16 +00:00
weichen 6162817ea3 Wire the scoped-host factory through the composition root
Why: The red run pinned the missing wiring; downstream recovery paths need a legal handle outside dispatch.
Evidence: TestScopedHostFactory green (fail-closed before wiring; Owner/PluginID, KV roundtrip, prefix enforcement, empty-arg rejection after); go test ./... all packages ok.
Impact: wireScopedHostFactory injected in app.Run after hostcaps assembly; no dispatch-path change.
Refs: EXT-11; red run at 182d8b5.
2026-10-08 17:01:51 +00:00
weichen 182d8b5143 Add the EXT-11 scoped-host surface with a red factory test
Why: EXT-11 is accepted; the surface (SetScopedHostFactory/NewScopedHost, SurfaceVersion 2.4.0-experimental) must land with the behavioral red pinned - wiring absent, NewScopedHost fails closed with ErrNotLinked.
Evidence: TestScopedHostFactory red at the wired-factory assertion; row follows on the task card.
Impact: pkg/extension additive surface (surface.txt +2 symbols); internal wireScopedHostFactory stub. Behavior unchanged.
Refs: EXT-11 proposal; task card.
2026-10-08 17:00:53 +00:00
weichen 8a9a354649 Merge pull request 'Implement shared knowledge retrieval, card mirror switch, and SSE project gate (EXT-8 slice 2)' (#107) from feature/ext8-slice2-shared-retrieval into main
ci / vet-and-test (push) Successful in 41s
2026-10-08 16:24:19 +00:00
weichen 1de663a8d3 Implement shared knowledge retrieval, card mirror switch, and SSE project gate
ci / vet-and-test (pull_request) Successful in 40s
Why: The three pinned reds define EXT-8 slice 2 - adapter-granted cross-owner knowledge must be retrievable, conversation cards must stay out of public project streams when the host opts out, and project-stream subscription must honor the authorizer.
Evidence: TestConvCardMirrorSwitch, TestSharedKnowledgeRetrieval, TestProjectSSEScopeGate green; go test ./... 32/32 packages ok; rows follow on the task card.
Impact: store gains predicate-based SearchKnowledgeShared (FTS/LIKE shared variants + PG); searchKnowledge appends adapter-authorized hits; cardsvc emit gate (no publish = no persistence = clean replay); events project scope consults authorizeSharedProject. Default behavior unchanged everywhere.
Refs: EXT-8/EXT-12 proposals; red run at surface commit.
2026-10-08 16:18:07 +00:00
weichen d3381b208b Add the EXT-12 mirror switch surface and slice-2 red tests
Why: Knowledge retrieval cross-owner and conversation-card mirroring are the registered EXT-8 slice-2 items; the fields/wiring land with the acceptance reds pinned first.
Evidence: mirror red (project channel still emitted with switch off); shared-retrieval red (grant yields 0 hits); SSE-scope red (grant yields non-200); rows follow on the task card.
Impact: Options.DisableConvCardMirror passthrough (extension/link/app); cardsvc.MirrorConvCards (default true = current behavior); SurfaceVersion 2.3.0-experimental. Behavior unchanged.
Refs: EXT-8/EXT-12 proposals; task card.
2026-10-08 16:08:20 +00:00
weichen c5575aab27 Merge pull request 'Implement EXT-9 idempotent knowledge publish and revoke with the upload index switch' (#106) from feature/ext-9-index-publish into main
ci / vet-and-test (push) Successful in 40s
2026-10-08 16:01:50 +00:00
weichen fbc924c240 Implement EXT-9 idempotent publish, prefix-enforced revoke, and the upload index switch
ci / vet-and-test (pull_request) Successful in 41s
Why: The red acceptance tests pin the EXT-9 semantics - held material must not enter retrieval until published, revoke must stop retrieval immediately and idempotently, manual sources must stay out of plugin reach.
Evidence: TestKnowledgePublishIdempotentAndRevoke and TestUploadPreIndexSwitch green; go test ./... 32/32 packages ok; rows follow on the task card.
Impact: hostcaps KnowledgePublish (per-ref row+vector cleanup then existing write path) and KnowledgeDelete (plugin:<id> prefix enforced); store DeleteKnowledgeBySource with vector cleanup; upload indexing gated by DisableUploadIndexing (default false = unchanged).
Refs: EXT-9 proposal; red run at surface commit.
2026-10-08 15:55:02 +00:00
weichen 1ee32822c3 Add the EXT-9 publish/revoke surface and red acceptance tests
Why: EXT-9 is the other step-4 hard prerequisite; the surface (Options.DisableUploadIndexing passthrough, Host.KnowledgePublish idempotent replace, Host.KnowledgeDelete prefix-enforced revoke, SurfaceVersion 2.2.0-experimental) must land with the acceptance reds pinned before implementation.
Evidence: app red = publish not implemented; server red = switch-on still indexes (hits=1); rows recorded on the task card.
Impact: pkg/extension additive surface (surface.txt updated); internal Options passthrough; Server.DisableUploadIndexing field; host method stubs. Behavior unchanged.
Refs: EXT-9 proposal; task card.
2026-10-08 15:53:39 +00:00
weichen 251c204a6e Merge pull request 'Consult the EXT-8 resource authorizer in artifact read paths' (#105) from feature/ext-8-resource-authorizer into main
ci / vet-and-test (push) Successful in 42s
2026-10-08 15:42:12 +00:00
weichen a4783d18e7 Consult the EXT-8 authorizer in project artifact read paths
ci / vet-and-test (pull_request) Successful in 41s
Why: With the surface and injection seam in place, the read endpoints must fall back to the adapter when owner-scoped lookup misses, keeping default-deny and fail-closed semantics.
Evidence: TestProjectResourceAuthorizerSharesReads green (default deny, shared list/meta/content via adapter, third-party denied, authorizer error fail-closed); go test ./... 32/32 packages ok.
Impact: listArtifacts/getArtifact/artifactContent consult authorizeSharedProject; store gains gated helpers (GetArtifactByID/ArtifactContentByID/ListProjectArtifactsAll) documented internal-only; writes untouched; SSE project stream and project card aggregate deliberately deferred to EXT-12 (conversation-card mirroring privacy).
Refs: EXT-8; red run at d89e748; task card.
2026-10-08 15:34:18 +00:00
weichen d89e74880b Add the EXT-8 authorizer surface and a red sharing test
Why: EXT-8 is accepted (Documents #49); the surface (Registry.RegisterResourceAuthorizer, ResourceAuthorizer, SurfaceVersion 2.1.0-experimental) plus the Server injection seam must land before the read-path consultation, and the behavioral red run proves the gap.
Evidence: go test ./internal/server -run TestProjectResourceAuthorizerSharesReads FAIL (shared read 404 with adapter set); default-deny section passes; row recorded on the task card.
Impact: pkg/extension additive surface (surface.txt updated); projectAuthz slot in hostcaps; Server.ProjectAuthz + setter; app wiring after compiled load; declarative registry fails closed. Read behavior unchanged.
Refs: EXT-8 in Documents 2026-10-08-ext-c1-c7-rdm-shared-contracts; task card.
2026-10-08 15:32:04 +00:00
weichen e0b1c27f1f Scope command audit storage and queries to the requesting owner
ci / vet-and-test (pull_request) Successful in 42s
Why: GET /api/commands/audit leaked every tenant's audit rows (command ids, entrances, conversation ids, param digests) to any authenticated user; owner was never recorded nor filtered.
Evidence: Red run at a7cc69b FAIL (alice sees bob's row); after fix go test ./... 32/32 packages ok, including TestCommandAuditOwnerScoping and the v18 backfill test (rows recorded on the task card).
Impact: v18 migration adds command_audit.owner with actor-based backfill (empty-actor rows stay invisible via API, kept in-table); AppendAudit/ListAudits gain owner params; cmdbus port and fakes updated; handler passes authenticated owner. No public extension surface change.
Refs: Task card R1; U0 proposal section 5 C7 finding.
2026-10-08 14:02:19 +00:00
weichen a7cc69b61e Add a red test for cross-tenant command audit leakage
Why: GET /api/commands/audit returns every tenant's audit rows; the regression test pins the desired owner-scoped behavior before the fix.
Evidence: go test ./internal/server -run TestCommandAuditOwnerScoping FAIL (owner alice sees bob's row; output recorded on the task card).
Impact: Test only; suite red on this commit by design.
Refs: Task card R1; U0 proposal section 5 C7 security finding.
2026-10-08 13:57:30 +00:00
weichen fe7c245f5c Merge pull request 'Point the nested squad pin at the a2a adapter and pin alignment' (#103) from feature/q3-squad-pin into main
ci / vet-and-test (push) Successful in 44s
Merge pull request 'Point the nested squad pin at the a2a adapter and pin alignment' (#103) from feature/q3-squad-pin into main
2026-10-08 04:03:24 +00:00
weichen aa03de4045 Point the nested squad pin at the a2a adapter and pin alignment
ci / vet-and-test (pull_request) Successful in 43s
Why: Dev-level pin consistency (check-pins hard-errors on nested drift);
squad main advanced to 819b0c5 (a2a adapter module #47, StepSpec.Brief #46,
nested agent-native pin #48).

Evidence: full backend suite green against 819b0c5 (SELF); CI authoritative
(GATE).

Impact: nested deps/agent-squad 633e853 -> 819b0c5; no backend source
changes (backend consumes squad via wfcompile/squadadapter surfaces which
are unchanged since #99-#102).

Refs: Q3 plan WP4/WP6; Dev card .task/feature-agent-stack-q3.md
2026-10-08 04:01:20 +00:00
weichen 4791b2e760 Merge pull request 'Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)' (#102) from feature/q3-ab-harness into main
ci / vet-and-test (push) Successful in 47s
Merge pull request 'Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)' (#102) from feature/q3-ab-harness into main
2026-10-08 03:49:31 +00:00
weichen 196c8c09f7 Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)
ci / vet-and-test (pull_request) Successful in 42s
Why: Q3 WP5 requires a same-batch dual-run harness (direct vs squad
compiled graph) that records differences without demanding byte-identical
output, feeding DecideSwitch evidence for the runtime.entry default-switch
decision.

Evidence: eval/ab_test.go mechanics (theme-based scoring, status/theme/
latency diff notes, P50, DecideSwitch wiring, RunFunc error tolerance,
empty batch); wfcompile TestABDemoBriefDualRun runs the demo.brief scenario
on both entries (direct = scripted single-agent answer; squad = real
RunCompiled with stub backend and judge) and logs the report: both sides
100/100, summary-form difference recorded, decision '切换条件已满足…合入前
需要产品确认'. Full backend suite green (SELF); CI authoritative (GATE).

Impact: new eval/ab.go harness (pure mechanics, no runtime dependency);
no product default is flipped - runtime.entry stays direct until product
confirmation per the DecideSwitch report semantics (evidence recorded in
the task card and PR).

Refs: Q3 plan WP5 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 03:46:54 +00:00
weichen 2e42ed83f0 Merge pull request 'Complete the delegation task brief with output format and tool sections (Q3 WP3)' (#101) from feature/q3-delegation-template into main
ci / vet-and-test (push) Successful in 1m15s
Merge pull request 'Complete the delegation task brief with output format and tool sections (Q3 WP3)' (#101) from feature/q3-delegation-template into main
2026-10-08 03:33:06 +00:00
weichen 7642dcec24 Complete the delegation task brief with output format and tool sections (Q3 WP3)
ci / vet-and-test (pull_request) Successful in 48s
Why: Q3 acceptance requires the delegation template to state goal, output
format, tools and boundaries; SubagentFactory's brief had four of six
sections and the kernel Request gained the two carriers (agent-native #40).

Evidence: adapter_test task-brief assertions extended (输出格式/工具面
sections present with declared values); full backend suite green (SELF);
CI authoritative (GATE).

Impact: nested deps/agent-native 1ac21c6 -> 37c5e6f (Request.OutputFormat/
Tools + delegate_task schema); SubagentFactory renders the two sections
when declared, omits them when empty (no behavior change for existing
callers).

Refs: Q3 plan WP3; task card .task/feature-agent-stack-q3.md
2026-10-08 03:31:03 +00:00
weichen 76b9cf36a8 Merge pull request 'Retire the legacy workflow executor and switch squad steps to agent-native (Q3 WP2)' (#100) from feature/q3-wp2-retire into main
ci / vet-and-test (push) Successful in 50s
Merge pull request 'Retire the legacy workflow executor and switch squad steps to agent-native (Q3 WP2)' (#100) from feature/q3-wp2-retire into main
2026-10-08 03:25:44 +00:00
weichen 70afe40b00 Retire the legacy workflow executor and switch squad steps to agent-native (Q3 WP2)
ci / vet-and-test (pull_request) Successful in 47s
Why: Q3 WP2 retires the SpawnSubagent-compression execution path: all
WorkflowDef execution now goes through the wfcompile compiled graph, and
squad steps run on the agent-native kernel backend instead of the
agent-system step backend.

Evidence: runtime-level P4 parity tests (workflow_compiled_test.go):
registered branch lookup/errors, planned branch confirmation gate +
dynamic 8-step cap fail-closed before any HITL, model script switch
default-off, missing-slot HITL collection with compile-time rendering,
judge stream routed to the configured lightweight model (nil = fail-closed
verifier-unavailable). squadadapter TestOrchestrateRunsOnKernelStub:
four-phase skeleton runs end-to-end on the kernel stub (write_file
materialization, seeded skills, exec allowlist). server TestSquadOrchestration
reworked: kernel steps run in-process (no subagent child runs). Full
backend suite green; wfcompile/squadadapter/runmgr green with -race
(pre-existing llm_test fake race on main recorded as debt, CI does not
gate -race).

Impact: DELETED runtime/workflow.go + workflow_script.go (legacy step loop,
critic, script host) and their tests; squadadapter stepBackend
'agent-system' + profileForRole deleted; BuildBackends exported and
registers only agent-native; Orchestrate DefaultBackend -> agent-native;
builtin roles declare agent-native with write_file added to allowlists and
writer run_shell -> run_command (kernel tool name); runmgr default workflow
runner -> CompiledWorkflowRunner; mock #workflow demo runs compiled
(host-only steps). Model script branch keeps the single script-level
confirmation (no extra plan confirmation), matching legacy.

Refs: Q3 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 03:24:30 +00:00
weichen a1aba1527a Merge pull request 'Add the WorkflowDef-to-squad compiler with host intent steps (Q3 WP1)' (#99) from feature/q3-wfcompile into main
ci / vet-and-test (push) Successful in 42s
Merge pull request 'Add the WorkflowDef-to-squad compiler with host intent steps (Q3 WP1)' (#99) from feature/q3-wfcompile into main
2026-10-08 02:38:39 +00:00
weichen 017c96baa5 Add the WorkflowDef-to-squad compiler with host intent steps (Q3 WP1)
ci / vet-and-test (pull_request) Successful in 44s
Why: Q3 WP1 requires core.WorkflowDef workflows to run as compiled squad
graphs (P4 limits and DeniedTools enforced at the compile entry) so the
legacy SpawnSubagent-compression executor can be retired in WP2.

Evidence: internal/wfcompile conformance (zero model, zero network; script
step runs the real codeorch host like the legacy tests): demo.brief shape
(hitl/prompt+critic/artifact/card mapping, evaluator-optimizer loop budget
= maxRetries+1, needs chain), P4 limits (dynamic >8 rejected, dynamic
script rejected, DeniedTools, critic >3, workflow retries >6), demo.brief
E2E (critic fail->retry->pass, drops record, artifact from step-ref file,
card, hitl sequence without plan confirmation for registered workflows,
BB out_length visible in the draft task brief), critic exhaustion fails
closed, demo.script E2E (plugin script exempt from confirmation, wf.tool/
ask/artifact/step callbacks), workflowScripts off fails, model script
double confirmation with reject paths. Full backend suite green (SELF);
CI authoritative (GATE).

Impact: new package internal/wfcompile (compiler + host IntentRunner +
RunCompiled driver); nested deps/agent-squad 06934bc -> 633e853 (StepSpec
.Brief). No existing backend behavior changes (legacy executor untouched
until WP2).

Refs: Q3 plan WP1 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 02:36:39 +00:00
weichen 06934bccab Merge pull request 'Point the nested squad pin at the Q2 topology library completion' (#98) from feature/q2-squad-pin into main
ci / vet-and-test (push) Successful in 43s
Merge pull request 'Point the nested squad pin at the Q2 topology library completion' (#98) from feature/q2-squad-pin into main
2026-10-08 01:35:07 +00:00
weichen 30bbf5483f Point the nested squad pin at the Q2 topology library completion
ci / vet-and-test (pull_request) Successful in 42s
Why: Q2 (topology library) landed in Agent-Squad main across WP1-4
(12 builtin topologies with scripted-backend conformance); backend consumes
squad via squadadapter and must track the completed surface.

Evidence: GOWORK=off go build ./... and go test ./... -count=1 green against
squad bcc8d08 (SELF); backend CI on the PR head is authoritative (GATE).

Impact: nested deps/agent-squad c462bc4 -> bcc8d08 (additive squad API:
topology registry, RunOptions knobs, mailbox/handoff/board state); no
backend source changes required.

Refs: Q2 plan (2026-10-07-agent-stack-q2q3-plans.md); Dev task card
.task/feature-agent-stack-q2.md
2026-10-08 01:32:45 +00:00
weichen 6e02a1f574 Merge pull request 'Point the nested core pin at the TypeScript contract authority update' (#97) from feature/n3-core-pin into main
ci / vet-and-test (push) Successful in 41s
Point the nested core pin at the TypeScript contract authority update
2026-10-07 18:05:37 +00:00
weichen 26bd9286ad Make the rewind happy test tolerant of the settle-release window
ci / vet-and-test (pull_request) Successful in 41s
Why: run terminal status persists before the conversation claim is
released; on the slow CI runner the rewind POST landed inside that
window and got a 409. Retry the POST until the claim frees (bounded).
Evidence: go test -count=5 ./internal/server/ -run TestRewindFilesHTTPHappy
green; full suite green.
Impact: test-only.
Refs: Agent-System-Backend#94 #97
2026-10-07 18:04:08 +00:00
weichen 31a4e7576a Point the nested core pin at the TypeScript contract authority update
ci / vet-and-test (pull_request) Failing after 40s
Why: Dev pins modules/core at a253a40b (the TS contract authority);
the combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-System-Core#24
2026-10-07 17:55:04 +00:00
weichen e59b25c816 Merge pull request 'Split usage accounting end to end, retry by default, and take the WP6 pins (N3 WP6)' (#96) from feature/n3-usage-retry-wp6 into main
ci / vet-and-test (push) Successful in 41s
Split usage accounting end to end, retry by default, and take the WP6 pins (N3 WP6)
2026-10-07 17:19:27 +00:00
weichen 8c5612a0a3 Split usage accounting end to end, retry by default, and take the WP6 pins (N3 WP6)
ci / vet-and-test (pull_request) Successful in 44s
Why: subagent runs recorded zero tokens, cache hits were invisible in
run records, the usage event skipped terminal paths, and stream retry
existed only on the direct-chat path (audit N20 residual).
Evidence: go test -count=1 ./... green; TestRetryAttemptsMapping
(setting 0=off→-1, N→N), TestDirectRunUsageSplitOnRecord (Last* and
cache land in the run usage), TestSubRunAccountsTokens (subagent
accounting), TestAddUsageAccumulatesSplitFields (handle chain); pins
carry Agent-Native#35/#37/#38/#39, Agent-Squad#41, Core Usage fields.
Impact: run.Usage carries Last*/cache on every path; subagent runs are
accounted; runtime.llmRetries maps onto the library default (setting 0
disables via -1); the verifier wraps retry+fallback explicitly; retry
events surface as process parts.
Refs: Agent-Native#35-#39, Agent-Squad#41, N3 plan WP6
2026-10-07 17:17:56 +00:00
weichen e5034e177d Merge pull request 'Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)' (#95) from feature/n3-memory-wp5 into main
ci / vet-and-test (push) Successful in 41s
Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)
2026-10-07 16:24:58 +00:00
weichen 766a96e817 Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)
ci / vet-and-test (pull_request) Successful in 41s
Why: memory recall lived as a product-side interface assertion with no
library port, AgentSpec.MemoryScope had no consumer, and the model had
no write-back path. The port (Agent-Native#34) now exists; the product
adapts it over memory_entries.
Evidence: go test -count=1 ./... green; TestStoreMemoryRecallScopeAndLimit
(scope filters and limit), TestStoreMemoryAppendRoundtrip (project
context inheritance), TestRunCtxStoreMemory (runCtx outlet),
TestDirectRunInjectsMemoryBlock (the [记忆] block reaches the request
with the pre-migration text shape and deterministic order).
Impact: assembly-side injection removed (the loop injects per request
— same text, no duplication); memory_write available on the kernel
path; recall defaults to all scopes (pre-migration semantics), with
AgentSpec.MemoryScope narrowing where hosts pass it.
Refs: Agent-Native#34, Agent-Squad#38, N3 plan WP5
2026-10-07 16:23:24 +00:00
weichen 80a7468523 Merge pull request 'Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)' (#94) from feature/n3-checkpoint-wp3 into main
ci / vet-and-test (push) Successful in 42s
Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)
2026-10-07 16:00:33 +00:00
weichen f862892544 Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)
ci / vet-and-test (pull_request) Successful in 42s
Why: the library now pins eligibility, guards symlinks, reports partial
restores, and bounds the log (Agent-Native#33); the product wires
retention after successful rewinds and needed the HTTP-layer and
eligibility evidence.
Evidence: go test -count=1 ./... green; TestRewindFilesHTTPHappy and
TestRewindFilesHTTPBusy409 (HTTP happy + conflict),
TestBackendCheckpointEligibilityIsWriteFileOnly (the product surface
checkpoints exactly {fs_write_file}).
Impact: checkpoints.jsonl is compacted to the latest 200 anchors after
each successful rewind.
Refs: Agent-Native#33, Agent-Squad#37, N3 plan WP3
2026-10-07 15:59:14 +00:00
weichen c392ed81ee Merge pull request 'Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)' (#93) from feature/n3-otel-wp4 into main
ci / vet-and-test (push) Successful in 44s
Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)
2026-10-07 15:42:21 +00:00
weichen 904742ca4b Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)
ci / vet-and-test (pull_request) Successful in 40s
Why: spans now follow the OTel GenAI convention (Agent-Native#32); the
product needs the settings surface for the exporter, a meaningful
service.name, and per-profile agent names.
Evidence: go test -count=1 ./... green; TestTelemetryFlagsDefaults
(otlpEndpoint registered default empty = no export, serviceName
default agent-system-backend); nested pins carry the convention work.
Impact: nothing is exported unless telemetry.otlpEndpoint (or the env
var) is set; agent spans are named by profile id.
Refs: Agent-Native#32, Agent-Squad#36, N3 plan WP4
2026-10-07 15:41:01 +00:00
weichen dcd7c9a444 Merge pull request 'Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)' (#92) from feature/n3-session-wp2 into main
ci / vet-and-test (push) Successful in 40s
Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)
2026-10-07 15:27:47 +00:00
weichen 9f3fdb26e2 Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)
ci / vet-and-test (pull_request) Successful in 42s
Why: the product conversation path had zero mid-turn durability
(llm_direct ran without Session — intents, recovery pairing, and
side-effect replay semantics never applied). Transcript-as-authority
would have frozen the assembled prefix, so the journal is wired in
JournalOnly mode: recovery runs against the transcript, the context
stays on the ctxasm pipeline, and recovered outcomes inject as a
system note.
Evidence: go test -count=1 ./... green;
TestDirectRunPairsAfterKillSeed (crash-window transcript gets the
crashInterrupted pair, run proceeds) and
TestDirectRunSeedsStoreHistoryOnce (history appears exactly once in
every request, never enters the journal); the forged-marker and
harness suites pass unchanged.
Impact: conversation runs now journal tool intents and recover them on
the next run; no transparent resume (sealed interrupted stays; the
user keeps chatting). Opening the transcript fails soft (falls back to
the old no-session behavior).
Refs: Agent-Native#31, Agent-Squad#35, N3 plan WP2
2026-10-07 15:26:27 +00:00
weichen 7a6a6075c2 Merge pull request 'Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1)' (#91) from feature/n3-runstate-wp1 into main
ci / vet-and-test (push) Successful in 41s
Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1)
2026-10-07 15:08:58 +00:00
weichen 1770fac51f Align the forged-marker guard test with the N6 semantics
ci / vet-and-test (pull_request) Successful in 50s
Why: Stop.Replacement no longer rewrites the in-memory assistant; the
correction reaches the model as a user note. The guard's protective
intent is unchanged (forged body never replays as assistant speech).
Evidence: go test -count=1 ./... green.
Impact: test only.
Refs: Agent-Native#30, N3 plan WP1
2026-10-07 15:06:58 +00:00
weichen 97bc5093bd Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1)
Why: InterruptRunning wrote done.json{status:interrupted} for every
child, and ResumeInterruptedChildren skipped anything with done.json —
graceful shutdowns never resumed (only SIGKILL-style deaths did). done
markers now split by status: terminal states block resume, interrupted
does not. Nested pins carry repairOrphanTail and the N6 fix.
Evidence: go test -count=1 ./... green; TestResumeAfterGracefulInterruptMarker
(resumed once and only once), TestResumeSkipsCompletedMarker (terminal
states still block).
Impact: after any restart, interrupted children with a stored request
re-spawn exactly once regardless of how the process stopped.
Refs: Agent-Native#30, Agent-Squad#34, N3 plan WP1
2026-10-07 15:05:57 +00:00
weichen 7980a8a219 Merge pull request 'Give subagents per-run transcripts, resume, persisted notices, and the Background request field (N2 WP5)' (#90) from feature/n2-subagent-wp5 into main
ci / vet-and-test (push) Successful in 45s
Give subagents per-run transcripts, resume, persisted notices (N2 WP5)
2026-10-07 14:25:04 +00:00
weichen 358cfe017c Give subagents per-run transcripts, resume, persisted notices, and the Background request field (N2 WP5)
ci / vet-and-test (pull_request) Successful in 54s
Why: child runs had no independent transcript, no resume path (sealed
interrupted only), in-memory completion notices lost on restart, and
the subagent Background declaration had no consumer.
Evidence: go test -count=1 ./... green; TestSubagentTranscriptFilesWritten
(request/transcript/done files), TestResumeInterruptedChildrenRespawns
(re-spawn with original task + transcript-path hint, resumed.json
idempotent), TestNoticesPersistAndRedeliverAfterRestart (persist →
reload → deliver → delete), migration v17 pending_notices with up/down
on both dialects.
Impact: per-run transcript dirs under the workspace root (auxiliary,
write failures never abort spawns); interrupted children with a stored
request re-spawn as background runs at startup (pre-WP5 runs stay
sealed); notices survive restart; delegate factory honors
Request.Background with a sync fallback when the host lacks background
support.
Refs: Agent-Native#29, Agent-Squad#33, N2 plan WP5
2026-10-07 14:23:39 +00:00
weichen b44f78b09a Merge pull request 'Validate plugin and bundled skills against the open spec (N2 WP4)' (#89) from feature/n2-skills-wp4 into main
ci / vet-and-test (push) Successful in 51s
Validate plugin and bundled skills against the open spec (N2 WP4)
2026-10-07 13:58:47 +00:00
weichen df6745e820 Point the nested native and squad pins at the skills spec validation
ci / vet-and-test (pull_request) Successful in 41s
Why: internal/skills/validate.go calls ValidateSkillDocument, which
only exists on agent-native main after #28; the pin must move for CI
to build the new code.
Evidence: nested pins at 7425b32b / 9d3faefb (merged mains).
Impact: none beyond the pins.
Refs: Agent-Native#28, Agent-Squad#32
2026-10-07 13:57:52 +00:00
weichen 48c5202de2 Validate plugin and bundled skills against the open spec (N2 WP4)
Why: plugin skills were written to the skill root unvalidated and the
bundled recipes carried no name — both now go through the library's
spec validator (name required + kebab-case + equals dir, description
required, known-keys whitelist).
Evidence: go test -count=1 ./... green; TestV2InvalidSkillRejected pins
the fail-closed path with plugin context + field location; Seed
validates each SKILL.md before write (recipes dogfood-compliant);
nested pins carry agent-native#28 (the validator).
Impact: invalid plugin skills now abort the plugin load with a located
error instead of landing silently; the architecture baseline gains
backend/plugins→backend/skills.
Refs: Agent-Native#28, Agent-Squad#32, N2 plan WP4
2026-10-07 13:57:17 +00:00
weichen 1291b378c0 Merge pull request 'Point the nested native and squad pins at the MCP 2026-07-28 work' (#88) from feature/n2-wp3-cascade into main
ci / vet-and-test (push) Failing after 45s
Point the nested native and squad pins at the MCP 2026-07-28 work
2026-10-07 13:43:33 +00:00
weichen 7365b4c471 Point the nested native and squad pins at the MCP 2026-07-28 work
ci / vet-and-test (pull_request) Successful in 46s
Why: native 77e7f978 (Agent-Native#27, N2 WP3) and the squad cascade
carry the MCP 2026-07-28 alignment; the nested pins follow for the Dev
combination check.
Evidence: go build + go test -count=1 ./... green on this combination.
Impact: none beyond the pins.
Refs: Agent-Native#27, Agent-Squad#31
2026-10-07 13:42:14 +00:00
weichen 5dfe70d68d Merge pull request 'Point the nested native and squad pins at the Anthropic client work' (#87) from feature/n2-wp2-cascade into main
ci / vet-and-test (push) Successful in 54s
Point the nested native and squad pins at the Anthropic client work
2026-10-07 13:26:33 +00:00
weichen 7aa3bc447d Point the nested native and squad pins at the Anthropic client work
ci / vet-and-test (pull_request) Successful in 44s
Why: native d6053baf (Agent-Native#26, N2 WP2) and squad 41ea2766
(Agent-Squad#30) carry the native Anthropic client; the nested pins
follow for the Dev combination check.
Evidence: go build + go test -count=1 ./... green on this combination.
Impact: none beyond the pins.
Refs: Agent-Native#26, Agent-Squad#30
2026-10-07 13:24:45 +00:00
weichen 8858ca9b8a Merge pull request 'Align context trimming to tool groups and take the N2 WP1 pins' (#86) from feature/n2-context-wp1 into main
ci / vet-and-test (push) Successful in 42s
Align context trimming to tool groups and take the N2 WP1 pins
2026-10-07 13:13:09 +00:00
weichen 7acbefd60d Align context trimming to tool groups and take the N2 WP1 pins
ci / vet-and-test (pull_request) Successful in 44s
Why: trimOldest dropped single messages without tool-pair awareness —
after leading users were evicted it could orphan tool results or leave
dangling calls (acceptance clause 2's product-side gap).
Evidence: go test -count=1 ./... green; TestTrimOldestKeepsToolPairs
(pairing holds across repeated trims), TestTrimOldestDropsLeadingOrphanToolRun,
TestTrimOldestStopsWhenGroupCrossesCurrentTurn, TestPrefixHashStableForIdenticalInput;
native pins carry the spill and cache-contract work (Agent-Native#25).
Impact: trimming now evicts whole tool groups; the window may be
slightly exceeded when a group would cross the current turn — pairing
is never broken. Report.Truncations still records the count.
Refs: Agent-Native#25, Agent-Squad#29, N2 plan WP1
2026-10-07 13:11:47 +00:00
weichen 68addfda05 Merge pull request 'Point the nested squad and native pins at the CWD-clean fix' (#85) from feature/native-cascade-follow into main
ci / vet-and-test (push) Successful in 42s
Point the nested squad and native pins at the CWD-clean fix
2026-10-07 11:52:29 +00:00
weichen fcf2c73589 Point the nested squad and native pins at the CWD-clean fix
ci / vet-and-test (pull_request) Successful in 43s
Why: native ead690be keeps workspace-less requests out of the process
CWD (the Dev CI build step requires a clean tree after make test);
squad 029edc08 carries it.
Evidence: go build + go test ./... green on this combination.
Impact: none beyond the pins.
Refs: Agent-Native#24, Agent-Squad#28, Agent-System-Dev#242
2026-10-07 11:51:11 +00:00
weichen 33e261b224 Merge pull request 'Point the nested squad and native pins at the I2 cascade' (#84) from feature/agent-stack-i2-cascade into main
ci / vet-and-test (push) Successful in 46s
Point the nested squad and native pins at the I2 cascade
2026-10-07 11:34:20 +00:00
weichen fb90e6239a Point the nested squad and native pins at the I2 cascade
ci / vet-and-test (pull_request) Successful in 44s
Why: squad main (551e11ec) and native main (3a1ad5a) now carry the
nested agent-intent 343b1e6 pins the Dev combination check requires;
the backend nested copies follow.
Evidence: go build ./... green on this combination; trees identical
to the merged library mains.
Impact: none beyond the pins.
Refs: Agent-Squad#27, Agent-Native#23, I2 increment 3 cascade
2026-10-07 11:33:14 +00:00
weichen 9d2937115c Merge pull request 'Wire the intent slow lane behind settings flags (I2 WP3)' (#83) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 40s
Wire the intent slow lane behind settings flags (I2 WP3)
2026-10-07 11:23:57 +00:00
weichen 3f5c0f60e4 Point the nested agent-intent pin at the I2 port work
ci / vet-and-test (pull_request) Successful in 43s
Why: the slow-lane wiring references SemanticScorer/CommandGenerator,
which only exist on agent-intent main after #9/#10; the pin must move
for CI to build the new packages.
Evidence: deps/agent-intent now at 343b1e6 (merged main).
Impact: none by itself.
Refs: Agent-System-Backend#83, I2 increment 3
2026-10-07 11:22:58 +00:00
weichen 45b011af9f Wire the intent slow lane behind settings flags (I2 WP3)
ci / vet-and-test (pull_request) Failing after 34s
Why: I2 increments 1-2 shipped the agent-intent ports (SemanticScorer,
CommandGenerator); the backend still had no implementation, so the slow
lane could not be enabled in the product.
Evidence: go test ./... green; lint-boundaries ok; the two new
runtime.intentSemantic / runtime.intentGenerator flags are registered
with default false and the gate defaults to the I1 recognizer when both
are off (TestIntentSlowLaneFlagsDefaultOff, TestGate*).
Impact: no behavior change by default; enabling runtime.intentSemantic
routes ambiguous rule matches through embedding cosine scoring, and
runtime.intentGenerator lets the lightweight model propose commands on
fallback with library-side fail-closed validation. Nested agent-intent
pin moves to 343b1e6 (I2 increments 1-2).
Refs: I2 increment 3
2026-10-07 11:20:41 +00:00
weichen 3261135126 Merge pull request 'Point the nested agent-squad pin at the SQLite convergence' (#82) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 10:33:02 +00:00
weichen 3de400b740 Point the nested agent-squad pin at the SQLite convergence
ci / vet-and-test (pull_request) Successful in 40s
Why: Agent-Squad PR #26 landed the SQLite Store, single-DB mounting
and the Executor-port fix; backend must carry the same head or Dev
check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 10:32:16 +00:00
weichen 5883d10889 Merge pull request 'Point the nested agent-squad pin at the Store and termination work' (#81) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 41s
2026-10-07 10:03:14 +00:00
weichen 58d9a17b8a Point the nested agent-squad pin at the Store and termination work
ci / vet-and-test (pull_request) Successful in 42s
Why: Agent-Squad PR #25 landed the Store port, tiered checkpoints
and the termination registry; backend must carry the same head or
Dev check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 10:02:28 +00:00
weichen 7ea21c3ca2 Merge pull request 'Point the nested agent-squad pin at the attempt-id recovery' (#80) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 09:20:29 +00:00
weichen 15fb79e30e Point the nested agent-squad pin at the attempt-id recovery
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Squad PR #24 landed attempt ids and product-checked
interrupted recovery; backend must carry the same head or Dev
check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 09:19:43 +00:00
weichen a59980898f Merge pull request 'Point the nested agent-squad pin at the superstep engine' (#79) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 09:09:54 +00:00
weichen 617a4566be Point the nested agent-squad pin at the superstep engine
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Squad PR #23 landed the superstep engine; backend must
carry the same squad head or Dev check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 09:09:08 +00:00
weichen 6f27567acd Merge pull request 'Point the nested agent-squad pin at the native-archive cascade head' (#78) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 41s
2026-10-07 08:34:58 +00:00
weichen 64e80c9bb6 Merge origin/main into the I1 backend branch
ci / vet-and-test (pull_request) Successful in 42s
2026-10-07 08:31:44 +00:00
weichen 40ebca7f07 Point the nested agent-squad pin at the native-archive cascade
ci / vet-and-test (pull_request) Successful in 41s
Why: Squad PR #22 landed the nested native bump; backend must
carry the same squad head or Dev check-pins fails.

Evidence: squad main head is the #22 merge.

Impact: Nested squad pin only.
2026-10-07 08:28:45 +00:00
weichen 12f50ef2dc Merge pull request 'Point the nested pins at the eval-v2 combination' (#77) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 08:26:13 +00:00
weichen 0a26fb598b Point the nested pins at the eval-v2 combination
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Intent PR #8 and its cascades (Squad #21, Native #22)
landed; backend's nested pins must carry those heads or Dev
check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Three nested gitlinks.
2026-10-07 08:25:27 +00:00
weichen 07250a221a Merge pull request 'Project the command types through the intent gate surface' (#76) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 07:59:08 +00:00
weichen 517ae236b3 Project the command types through the intent gate surface
ci / vet-and-test (pull_request) Successful in 42s
Why: The boundary linter allows agent-intent imports only in the
adapter layer; route and runmgr consume aliases from intentgate
instead of importing the ecosystem library directly.

Evidence: lint-boundaries passes below; backend suite green.

Impact: intentgate re-exports Command/ActionRef and the kind
constants; route drops the agent-intent import.
2026-10-07 07:58:05 +00:00
weichen a70b5a2f6a Merge pull request 'Point the nested pins at the I1 router combination' (#75) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 07:51:22 +00:00
weichen 6c7563bd15 Point the nested pins at the I1 router combination
ci / vet-and-test (pull_request) Successful in 41s
Why: Squad PR #20 and Agent-Native PR #21 completed the intent
cascade; backend's nested pins must carry those heads or Dev
check-pins fails.

Evidence: squad and native heads are direct merges on their mains;
backend suite green before the pin bump.

Impact: Two nested gitlinks.
2026-10-07 07:50:04 +00:00
weichen 4da3cfc787 Merge pull request 'Unify intent routing behind the route package and enable NFKC (I1 WP3/WP4)' (#74) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 07:46:07 +00:00
weichen ff43c7c9aa Unify intent routing behind the route package and enable NFKC
ci / vet-and-test (pull_request) Successful in 41s
Why: I1 WP3 - chat routing and the HITL free-text pivot now share one
router consuming the library's command list, and both intent hosts
fold compatibility-equivalent input.

Evidence: go vet clean; go test ./... 30 packages ok, including the
existing workflow-route and server e2e suites unchanged (behavior
parity) and the new route package tests.

Impact: new internal/route package (unique router with explicit
priority rules); intentgate carries Commands and folds NFKC;
server.go and runmgr.go route through it; nested pins at intent
2554b50 and squad 6b7a991. Blueprint actions resolve to a squad
decision at the router level; per-run squad entry stays a recorded
follow-up (no silent misrouting).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP3/WP4
2026-10-07 07:44:31 +00:00
weichen 303983bd3e Merge pull request 'Point the nested agent-squad pin at the WP5 cascade head' (#73) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 06:40:18 +00:00
weichen 6526a0b0e3 Point the nested agent-squad pin at the WP5 cascade head
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Squad PR #18 landed the nested agent-native WP5 bump;
backend must carry the same squad head or Dev check-pins fails.

Evidence: squad main head is the #18 merge; backend suite runs next.

Impact: Nested squad pin only.
2026-10-07 06:38:36 +00:00
weichen 395a9907d7 Merge pull request 'Consume the sunk gate mechanics and pin the WP5 library (N1 WP5/WP6)' (#72) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 06:36:10 +00:00
weichen 4e3c0b60c1 Merge origin/main into the N1 backend branch
ci / vet-and-test (pull_request) Successful in 41s
2026-10-07 06:35:38 +00:00
weichen 5f9e77a500 Consume the sunk gate mechanics and pin the WP5 library
ci / vet-and-test (pull_request) Successful in 40s
Why: N1 WP5 host side - directRun.stop and the checklist reminders
now call the library gates package behind product ports, and the
nested native pin moves to the merged WP5 head.

Evidence: go vet clean; go test ./... 29 packages ok, including the
characterization suite unmodified and the new wp5_ab_test.go
(verify-cap continuation counts and gate-reason text parity).

Impact: llm.go drops the local regex family and deliverableGate;
verify.go drops checkDeliverables/parseVerdict/stripFence and the
local caps; workflow.go switches to gates.Verdict; reminders run
through gates.ProgressReminder. Behavior is pinned identical by the
existing suite plus the A/B tests. Squad reminders stay out of this
increment (adapter lacks per-member tool-turn signals - recorded as
WP6b).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6
2026-10-07 06:31:58 +00:00
weichen f0b0db9aaa Merge pull request 'Validate plugin agent declarations and wire the squad ask port (N1 WP1/WP4)' (#71) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 39s
2026-10-07 05:15:36 +00:00
weichen dc6d9985d8 Validate plugin agent declarations and wire the squad ask port
ci / vet-and-test (pull_request) Successful in 41s
Why: N1 increment three host side - plugin agent assets must reject
hooks/mcp/permissionMode declarations (WP1 acceptance), and squad
members need the kernel ask port wired to the HITL card (WP4).

Evidence: go vet clean; go test ./... 29 packages ok, including new
agent-asset tests (banned keys located to the field, unknown keys,
missing id) and the V2 asset suite. Nested pins at native c5b9dca
(PR #18) and squad fc9620e (PR #17).

Impact: plugins agents/ assets now parse via parseAgentAsset
(strict JSON with banned-key rejection); PermissionAskOf exported
and takes any host with the optional method; app.go wires
cfg.PermissionAsk for the squad path. Legacy behavior unchanged when
nothing registers the port.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
2026-10-07 05:14:06 +00:00
weichen d34c2e8568 Merge pull request 'Point the nested agent-squad pin at the cascaded N1 combination' (#70) from feature/agent-stack-n1-pins into main
ci / vet-and-test (push) Successful in 39s
2026-10-07 04:44:36 +00:00
weichen 9239353239 Point the nested agent-squad pin at the cascaded N1 combination
ci / vet-and-test (pull_request) Successful in 39s
Why: Agent-Squad PR #16 landed the nested agent-native bump; backend
must carry the same squad head or Dev check-pins fails.

Evidence: squad main head is the #16 merge; backend test suite runs
next.

Impact: Nested squad pin only.
2026-10-07 04:43:20 +00:00
weichen fe0c5a4f1b Merge pull request 'Wire the permission ask port to HITL and adopt the Policy object (N1 WP2/WP3)' (#69) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 38s
2026-10-07 04:38:53 +00:00
weichen 4075cc6501 Point backend at the merged N1 ask-loop and Policy increment
ci / vet-and-test (pull_request) Successful in 38s
Why: Library PR #17 landed on agent-native main as c2befaf; backend
compiles with the merged combination, not the pre-merge branch head.

Evidence: go vet and full go test run next; native main ancestry of
9758a8d verified before the pin bump.

Impact: Nested agent-native pin only.
2026-10-07 04:37:43 +00:00
weichen 16dbe70014 Merge origin/main into the N1 backend branch 2026-10-07 04:37:33 +00:00
weichen 17f7e2b168 Wire the permission ask port to HITL and adopt the Policy object
ci / vet-and-test (pull_request) Successful in 40s
Why: N1 WP2/WP3 host side - the kernel ask loop must reach the HITL
confirm card, and tool gating should run through the typed Policy with
the session-allow store port instead of a hand-built Request.

Evidence: go vet clean; go test ./... all packages ok, including new
TestConvAllowStoreKeysByName, TestPermissionAskRoutesToHITL, and
permissionAskOf tests. Nested agent-native pin at 9758a8d (PR #17).

Impact: runmgr.ExecuteTool decides via agentpolicy.Policy with
convAllowStore (keying unchanged: conversation x tool name);
runCtx.PermissionAsk routes kernel asks to the shared toolConfirmCard
HITL; direct and subagent loops pass PermissionAsk into the kernel
config (nil for hosts without the method, preserving legacy ask=reject).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP2/WP3; Agent-Native PR #17
2026-10-07 04:04:10 +00:00
weichen 838f7a5e14 Merge pull request 'Skip wide sparse columns without dropping later xlsx sheets' (#68) from fix/ext-5-wide-cols into main
ci / vet-and-test (push) Successful in 41s
2026-10-06 15:39:42 +00:00
weichen e7fb276ee9 Skip wide sparse columns without dropping later xlsx sheets
ci / vet-and-test (pull_request) Successful in 41s
Why: review of EXT-5 found a column-cap cell (common styled XFD cells) wrongly reported the 200k-cell cap and aborted every remaining worksheet; malformed sheet XML was also dropped silently, contradicting the explicit-failure contract; the legacy-office parser id was missing from the registry.

Evidence: local GOWORK=off go test ./... 29 packages pass with new regressions (wide column keeps later sheets, bad sheet XML warns, registry maps doc/xls/ppt); go vet clean; recorded as SELF on the Dev task card.

Impact: xlsxSheetBlocks returns wide/badXML signals; wide cells skip with a per-sheet warning, cell-cap truncation unchanged; builtin:legacy-office added to ParsersDescription.

Refs: review of backend 727f370; documents proposal 2026-10-06-ext-5-host-parse-document sect.2.
2026-10-06 15:30:50 +00:00
weichen 736f0a0839 Merge pull request 'Expose builtin document parsing through the host handle' (#67) from feature/ext-5-host-parse into main
ci / vet-and-test (push) Successful in 43s
2026-10-06 15:14:21 +00:00
weichen 727f3702d0 Expose builtin document parsing through the host handle
ci / vet-and-test (pull_request) Successful in 46s
Why: downstream parsers are unreachable via extension.Host and the base lacked xlsx/pptx parsers; MS1 needs Excel and PPT ingest (EXT-5 request, documents proposal 2026-10-06-ext-5-host-parse-document).

Evidence: local GOWORK=off go test ./... all 29 packages pass incl. new ooxml_test (hand-built parts), ooxml_python_test (openpyxl/python-pptx round-trip), inproc owner-scope and limit tests, xlsx upload e2e; go vet clean; recorded as SELF on the Dev task card, PR CI to confirm.

Impact: Host gains ParseDocument (pure compute; errors only on ctx cancel or >100MiB input); builtin:xlsx (sheet order via workbook.xml rels, rich-text shared strings, formula cache, 1900/1904 dates, sparse cells) and builtin:pptx (sldIdLst order, titles, group shapes, AlternateContent Choice, tables, notes) added with 200k-cell/64MiB-part caps; legacy doc/xls/ppt get an actionable hint; ParsersDescription gains xlsx/pptx and the missing text ext; surface snapshot now records exported interface methods; docx part reads share the 64MiB cap.

Refs: documents 2026-10-06-ext-5-host-parse-document.md; EXT-1 host.go precedent.
2026-10-06 14:59:38 +00:00
weichen f9212d01b7 Merge pull request 'Merge EXT-1 inproc plugin command handler into main' (#66) from feature/ext-1-inproc-handler into main
ci / vet-and-test (push) Successful in 41s
2026-10-06 01:25:37 +00:00
112 changed files with 9283 additions and 1658 deletions
+7 -2
View File
@@ -44,13 +44,14 @@ func main() {
instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)")
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
fs.Parse(os.Args[1:])
cliSet := visited(fs)
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,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -60,6 +61,7 @@ func main() {
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,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -76,6 +78,8 @@ func visited(fs *flag.FlagSet) map[string]bool {
name = "insecureListen"
case "allowed-hosts":
name = "allowedHosts"
case "disable-exec-tools":
name = "disableExecTools"
}
set[name] = true
})
@@ -100,11 +104,12 @@ func runConfig(args []string) int {
instance := fs.String("instance", "", "")
insecure := fs.Bool("insecure-listen", false, "")
allowedHosts := fs.String("allowed-hosts", "", "")
noExec := fs.Bool("disable-exec-tools", false, "")
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,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
+175
View File
@@ -0,0 +1,175 @@
package eval
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// ab.go — 任务级 A/B 双跑 harness(Q3 WP5):同一批任务分别经两个入口
// (direct 对话循环 / squad 编译图)执行,逐任务记录差异(不要求字节级
// 相同),汇总为两份 EntryScore 交 DecideSwitch 裁决默认入口切换。
// 本包保持纯机制(不依赖 runtime/squad);两侧 RunFunc 由调用方装配。
// ABTask 是对照批中的一个任务。
type ABTask struct {
ID string
Goal string
// Themes 期望产出要点(短词,非整句照抄):打分用 MissingThemes,
// 差异记录用要点覆盖对比。
Themes []string
}
// ABOutcome 是一个入口对一个任务的执行结果(调用方 RunFunc 填写)。
type ABOutcome struct {
// Completed 任务是否正常收口(失败/取消 = false)。
Completed bool
// Summary 交付摘要(要点覆盖打分的对象;调用方可拼接工件正文)。
Summary string
// FirstToken 首字延迟(入口收到任务到首个可见输出)。
FirstToken time.Duration
// Err 失败原因(Completed=false 时填写)。
Err string
}
// RunFunc 是一个入口的执行侧(direct 或 squad 编译图)。
type RunFunc func(ctx context.Context, task ABTask) (ABOutcome, error)
// ABTaskRecord 是一个任务的双跑对照记录(差异记录单位)。
type ABTaskRecord struct {
Task ABTask
Direct ABOutcome
Squad ABOutcome
// Diffs 差异注记(状态 / 要点覆盖 / 首字延迟),空 = 两侧一致。
Diffs []string
// DirectPoints / SquadPoints 单任务得分(满分 100:完成 + 要点全覆盖;
// 未完成 0 分;完成但缺要点按缺失比例扣)。
DirectPoints int
SquadPoints int
}
// ABReport 是一批双跑的对照报告(切换裁决的证据形态)。
type ABReport struct {
Records []ABTaskRecord
Direct EntryScore
Squad EntryScore
Decision SwitchDecision
// Text 是可附在切换 PR / 任务卡上的短报告(Report(Decision) + 差异清单)。
Text string
}
// RunAB 双跑同一批任务并汇总对照报告。任一侧 RunFunc 返回 error 视为该侧
// 该任务失败(记录进 outcome.Err),不中断整批(对照要的是全批差异面)。
func RunAB(ctx context.Context, tasks []ABTask, direct, squad RunFunc) ABReport {
rep := ABReport{}
if len(tasks) == 0 {
rep.Decision = DecideSwitch(nil, nil)
rep.Text = Report(rep.Decision)
return rep
}
var directFT, squadFT []time.Duration
var directSum, squadSum int
for _, task := range tasks {
rec := ABTaskRecord{Task: task}
rec.Direct = runOne(ctx, task, direct)
rec.Squad = runOne(ctx, task, squad)
rec.DirectPoints = scoreOutcome(rec.Direct, task.Themes)
rec.SquadPoints = scoreOutcome(rec.Squad, task.Themes)
rec.Diffs = diffRecord(rec)
rep.Records = append(rep.Records, rec)
directSum += rec.DirectPoints
squadSum += rec.SquadPoints
directFT = append(directFT, rec.Direct.FirstToken)
squadFT = append(squadFT, rec.Squad.FirstToken)
}
rep.Direct = EntryScore{Name: "direct", Score: directSum / len(tasks), FirstTokenP50: p50(directFT)}
rep.Squad = EntryScore{Name: "squad", Score: squadSum / len(tasks), FirstTokenP50: p50(squadFT)}
rep.Decision = DecideSwitch(&rep.Direct, &rep.Squad)
rep.Text = renderReport(rep)
return rep
}
func runOne(ctx context.Context, task ABTask, fn RunFunc) ABOutcome {
if fn == nil {
return ABOutcome{Err: "入口未装配"}
}
out, err := fn(ctx, task)
if err != nil {
out.Completed = false
if out.Err == "" {
out.Err = err.Error()
}
}
return out
}
// scoreOutcome 单任务打分:未完成 0;完成 = 100 - 缺失要点比例 × 100。
func scoreOutcome(out ABOutcome, themes []string) int {
if !out.Completed {
return 0
}
if len(themes) == 0 {
return 100
}
missing := MissingThemes(out.Summary, themes)
return 100 - len(missing)*100/len(themes)
}
// diffRecord 逐任务差异注记(状态 / 要点 / 首字延迟;不要求字节级相同)。
func diffRecord(rec ABTaskRecord) []string {
var diffs []string
if rec.Direct.Completed != rec.Squad.Completed {
diffs = append(diffs, fmt.Sprintf("状态:direct=%v squad=%v(%s / %s)",
rec.Direct.Completed, rec.Squad.Completed, rec.Direct.Err, rec.Squad.Err))
}
if len(rec.Task.Themes) > 0 {
dm := MissingThemes(rec.Direct.Summary, rec.Task.Themes)
sm := MissingThemes(rec.Squad.Summary, rec.Task.Themes)
if strings.Join(dm, ",") != strings.Join(sm, ",") {
diffs = append(diffs, fmt.Sprintf("要点缺失:direct=%v squad=%v", dm, sm))
}
}
gap := rec.Squad.FirstToken - rec.Direct.FirstToken
if gap < 0 {
gap = -gap
}
if gap > time.Second {
diffs = append(diffs, fmt.Sprintf("首字延迟差:%s(direct=%s squad=%s)",
gap, rec.Direct.FirstToken, rec.Squad.FirstToken))
}
if rec.Direct.Completed && rec.Squad.Completed &&
strings.TrimSpace(rec.Direct.Summary) != strings.TrimSpace(rec.Squad.Summary) {
diffs = append(diffs, "摘要文本不同(记录在案,不要求字节级相同)")
}
return diffs
}
func p50(ds []time.Duration) time.Duration {
if len(ds) == 0 {
return 0
}
sorted := append([]time.Duration(nil), ds...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
return sorted[len(sorted)/2]
}
// renderReport 报告全文:裁决 + 分数 + 逐任务差异清单。
func renderReport(rep ABReport) string {
var b strings.Builder
b.WriteString(Report(rep.Decision))
fmt.Fprintf(&b, "\n分数:direct=%d squad=%d;首字P50:direct=%s squad=%s\n",
rep.Direct.Score, rep.Squad.Score, rep.Direct.FirstTokenP50, rep.Squad.FirstTokenP50)
for _, rec := range rep.Records {
fmt.Fprintf(&b, "- %s:direct %d 分 / squad %d 分", rec.Task.ID, rec.DirectPoints, rec.SquadPoints)
if len(rec.Diffs) == 0 {
b.WriteString("(无差异注记)")
}
b.WriteString("\n")
for _, d := range rec.Diffs {
fmt.Fprintf(&b, " - %s\n", d)
}
}
return strings.TrimRight(b.String(), "\n")
}
+124
View File
@@ -0,0 +1,124 @@
package eval
import (
"context"
"strings"
"testing"
"time"
)
// ab_test.go — Q3 WP5:A/B 双跑 harness 机制面(差异记录 / 打分 / P50 /
// DecideSwitch 联动)。真实入口的集成对照在 internal/wfcompile 的
// demo.brief 双跑测试。
func stubRun(completed bool, summary string, ft time.Duration, errText string) RunFunc {
return func(context.Context, ABTask) (ABOutcome, error) {
return ABOutcome{Completed: completed, Summary: summary, FirstToken: ft, Err: errText}, nil
}
}
func TestRunABRecordsDiffsAndScores(t *testing.T) {
tasks := []ABTask{
{ID: "t1", Goal: "写简报", Themes: []string{"太空电梯", "核对标记"}},
{ID: "t2", Goal: "查资料", Themes: []string{"三来源"}},
}
direct := func(_ context.Context, task ABTask) (ABOutcome, error) {
switch task.ID {
case "t1":
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报,核对标记:P4", FirstToken: 100 * time.Millisecond}, nil
default:
return ABOutcome{Completed: true, Summary: "对照完成,三来源齐", FirstToken: 200 * time.Millisecond}, nil
}
}
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
switch task.ID {
case "t1":
// 完成但缺一个要点 → 扣分 + 差异注记。
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报", FirstToken: 100 * time.Millisecond}, nil
default:
return ABOutcome{Completed: false, Err: "编译图步骤失败", FirstToken: 0}, nil
}
}
rep := RunAB(context.Background(), tasks, direct, squad)
if len(rep.Records) != 2 {
t.Fatalf("records = %d", len(rep.Records))
}
r1 := rep.Records[0]
if r1.DirectPoints != 100 || r1.SquadPoints != 50 {
t.Fatalf("t1 points = %d/%d", r1.DirectPoints, r1.SquadPoints)
}
if !strings.Contains(strings.Join(r1.Diffs, ";"), "要点缺失") {
t.Fatalf("t1 diffs = %v", r1.Diffs)
}
r2 := rep.Records[1]
if r2.SquadPoints != 0 || !strings.Contains(strings.Join(r2.Diffs, ";"), "状态") {
t.Fatalf("t2 = %d %v", r2.SquadPoints, r2.Diffs)
}
// 分数汇总:direct 100,squad (50+0)/2=25 → 不达标,保持 direct。
if rep.Direct.Score != 100 || rep.Squad.Score != 25 {
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
}
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "低于") {
t.Fatalf("decision = %+v", rep.Decision)
}
if !strings.Contains(rep.Text, "保持 direct") {
t.Fatalf("report = %s", rep.Text)
}
}
func TestRunABSwitchMetWhenEqual(t *testing.T) {
tasks := []ABTask{{ID: "t1", Goal: "g", Themes: []string{"要点"}}}
fn := stubRun(true, "覆盖要点的产出", 50*time.Millisecond, "")
rep := RunAB(context.Background(), tasks, fn, fn)
if rep.Direct.Score != 100 || rep.Squad.Score != 100 {
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
}
if !rep.Decision.Met {
t.Fatalf("同分同延迟应达标:%+v", rep.Decision)
}
if len(rep.Records[0].Diffs) != 0 {
t.Fatalf("同产出应无差异注记:%v", rep.Records[0].Diffs)
}
if rep.Direct.FirstTokenP50 != 50*time.Millisecond {
t.Fatalf("p50 = %s", rep.Direct.FirstTokenP50)
}
}
func TestRunABLatencyGate(t *testing.T) {
tasks := []ABTask{{ID: "t1", Goal: "g"}}
direct := stubRun(true, "ok", 100*time.Millisecond, "")
squad := stubRun(true, "ok", 4*time.Second, "") // 额外首字 > 3s
rep := RunAB(context.Background(), tasks, direct, squad)
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "超过 3 秒") {
t.Fatalf("decision = %+v", rep.Decision)
}
if !strings.Contains(strings.Join(rep.Records[0].Diffs, ";"), "首字延迟差") {
t.Fatalf("diffs = %v", rep.Records[0].Diffs)
}
}
// RunFunc 返回 error:记为该侧失败,不中断整批。
func TestRunABToleratesRunFuncError(t *testing.T) {
tasks := []ABTask{{ID: "t1"}, {ID: "t2"}}
direct := stubRun(true, "ok", 0, "")
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
if task.ID == "t1" {
return ABOutcome{}, context.DeadlineExceeded
}
return ABOutcome{Completed: true, Summary: "ok"}, nil
}
rep := RunAB(context.Background(), tasks, direct, squad)
if rep.Records[0].SquadPoints != 0 || rep.Records[0].Squad.Err == "" {
t.Fatalf("t1 = %+v", rep.Records[0].Squad)
}
if rep.Records[1].SquadPoints != 100 {
t.Fatalf("t2 应不受 t1 失败影响:%+v", rep.Records[1])
}
}
func TestRunABEmptyBatch(t *testing.T) {
rep := RunAB(context.Background(), nil, nil, nil)
if rep.Decision.Met {
t.Fatal("空批不得达标(无实测分数)")
}
}
+70 -5
View File
@@ -36,6 +36,7 @@ import (
"agent-system-backend/internal/config"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
"agent-system-backend/internal/pgbus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
@@ -65,6 +66,16 @@ type Options struct {
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
InsecureListen bool
AllowedHosts []string
// DisableUploadIndexing EXT-9:true 时上传端点不自动写检索索引(发布决定
// 移交下游命令层);零值=false=现状。由 link 层自 extension.Options 透传。
DisableUploadIndexing bool
// DisableConvCardMirror EXT-12:true 时会话卡不镜像进项目公共流(不发布
// 即不持久,重放不含会话卡);零值=false=现状全量镜像。
DisableConvCardMirror bool
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
// 零值=false=现状。
DisableExecTools bool
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -83,7 +94,6 @@ func Run(opt Options) error {
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
traceFn := telemetry.StartFunc(logger)
var st *store.Store
var err error
if opt.StorageDSN != "" {
@@ -112,7 +122,12 @@ func Run(opt Options) error {
}
// 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥,
// runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法
// 在提示词库 base.deliverables,不在系统工具里。
// 在提示词库 base.deliverables,不在系统工具里。启动参数 DisableExecTools
// 为不受用户设置影响的硬关闭(多用户部署的默认安全档)。
tools.SetExecDisabled(opt.DisableExecTools)
if opt.DisableExecTools {
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
}
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
@@ -123,6 +138,10 @@ func Run(opt Options) error {
return fmt.Errorf("code orchestration tool: %w", err)
}
setSvc := settings.NewService(st)
effBoot := setSvc.Effective(store.OwnerDefault, "")
traceFn := telemetry.StartFunc(logger,
telemetry.WithServiceName(effBoot.GetString("telemetry.serviceName", "agent-system-backend")),
telemetry.WithEndpoint(effBoot.GetString("telemetry.otlpEndpoint", "")))
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 点路径键展开必须在读 tools.tavily.apiKeys 之前,否则启动仍注册旧池。
norm, err := setSvc.NormalizeStored()
@@ -255,6 +274,9 @@ func Run(opt Options) error {
}
}
cfg.Trace = traceFn
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
CheckpointAnchor() string
@@ -294,6 +316,8 @@ func Run(opt Options) error {
if err := mgr.ReconcileInterrupted(); err != nil {
return fmt.Errorf("reconcile interrupted runs: %w", err)
}
// 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。
mgr.LoadPendingNotices()
mgr.SetSquadRunner(&runtime.SquadRunner{})
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
@@ -327,14 +351,30 @@ func Run(opt Options) error {
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
pipe := rag.NewPipeline(st, setSvc, nil, nil)
pipe.SetEmbedder(rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
embedder := rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "")
}})
}}
pipe.SetEmbedder(embedder)
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
mgr.SetRetriever(pipe)
mgr.SetIndexer(pipe)
// 意图慢通道(I2):语义向量裁决 + 轻量模型命令生成。
// 两个开关都默认关——此时识别行为与 I1 完全一致(fail-closed 分层);
// 开启语义需已配置 embedding 端点,开启生成需可用的轻量/主模型档。
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentSemantic", false) {
gate.SetSemantic(intentsem.EmbeddingScorer{Provider: embedder}, 0)
}
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentGenerator", false) {
effNow := setSvc.Effective(store.OwnerDefault, "")
light := runtime.ResolveLightweightModel(core.AgentProfile{}, effNow)
client := &agentllm.Client{BaseURL: light.Base, APIKey: light.Key, HTTPClient: &http.Client{}}
gate.SetGenerator(intentsem.LightGenerator{Stream: client.Stream, Model: light.Model})
}
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
mgr.ResumeInterruptedChildren()
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
@@ -345,7 +385,10 @@ func Run(opt Options) error {
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
caps := hostcaps{st: st, pipe: pipe, set: setSvc}
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
applyCompiled(skillsRoot, bus, caps)
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
@@ -369,11 +412,33 @@ func Run(opt Options) error {
hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return caps.searchDefault(ctx, owner, text)
}
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
own, err := caps.searchDefault(ctx, owner, text)
if err != nil {
return nil, err
}
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
ok, aerr := authzSlot.Authorize(owner, projectID)
return aerr == nil && ok
})
if err != nil {
return nil, err
}
return append(own, shared...), nil
}
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
return caps.complete(ctx, owner, prompt)
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
srv.DisableUploadIndexing = opt.DisableUploadIndexing
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
cardSvc.MirrorConvCards = !opt.DisableConvCardMirror
srv.Workflows = wfReg
mgr.SetWorkflows(wfReg)
srv.Cards = cardSvc
+6
View File
@@ -3,6 +3,7 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
@@ -65,6 +66,11 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
return r.bus.RegisterPluginCommands([]json.RawMessage{spec})
}
// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。
func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error {
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
return nil
+81
View File
@@ -0,0 +1,81 @@
package app
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// newExt9Host EXT-9 测试装配:真实 store + rag 管线 + inprocHost(无 embedder,
// embed=false 走全文档路径)。
func newExt9Host(t *testing.T) (interface {
KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error
KnowledgeDelete(ctx context.Context, sourcePrefix string) error
}, *store.Store, *rag.Pipeline) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"}
return h, st, pipe
}
func searchAll(t *testing.T, pipe *rag.Pipeline, owner, text string) []core.RetrievalHit {
t.Helper()
hits, err := pipe.Retrieve(context.Background(), core.ID(owner), core.RetrievalQuery{Text: text, TopK: 50})
if err != nil {
t.Fatalf("retrieve: %v", err)
}
return hits
}
// EXT-9 验收:publish 按 ref 幂等(重试不重复);delete 后检索即停且幂等;
// manual 来源不经 host 可删(前缀强制 plugin:<自身>)。
func TestKnowledgePublishIdempotentAndRevoke(t *testing.T) {
h, st, pipe := newExt9Host(t)
ctx := context.Background()
docs := []core.KnowledgeDoc{
{Ref: "batch-001", Chunk: "EXT9甲 独占令牌之一陈述"},
{Ref: "batch-001", Chunk: "EXT9乙 独占令牌之二陈述"},
}
if err := h.KnowledgePublish(ctx, docs, false); err != nil {
t.Fatalf("publish#1: %v", err)
}
if err := h.KnowledgePublish(ctx, docs, false); err != nil { // 重试幂等
t.Fatalf("publish#2: %v", err)
}
hits := searchAll(t, pipe, "alice", "独占令牌")
if len(hits) != 2 {
t.Fatalf("after double publish: hits=%d, want 2(幂等不重复;got %+v)", len(hits), hits)
}
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil {
t.Fatalf("delete: %v", err)
}
if hits = searchAll(t, pipe, "alice", "独占令牌"); len(hits) != 0 {
t.Fatalf("after delete: hits=%d, want 0(撤销即停)", len(hits))
}
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil { // 幂等
t.Fatalf("delete#2: %v", err)
}
// manual 来源:host 前缀强制拒绝,且内容不受插件删除影响。
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", Source: "manual", Ref: "m-1", Chunk: "manual 手册独占词"}); err != nil {
t.Fatal(err)
}
if err := h.KnowledgeDelete(ctx, "manual"); err == nil {
t.Fatal("delete(manual) must be rejected: plugins may only revoke their own prefix")
}
if hits = searchAll(t, pipe, "alice", "手册独占词"); len(hits) != 1 {
t.Fatalf("manual row must survive: hits=%d", len(hits))
}
}
+103 -4
View File
@@ -1,9 +1,12 @@
package app
import (
"agent-system-backend/pkg/extension"
"context"
"fmt"
"sync"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
@@ -15,9 +18,46 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
st *store.Store
pipe *rag.Pipeline
set *settings.Service
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
type projectAuthz struct {
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
}
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fn != nil {
return // 首个注册者生效;后来者忽略(调用方经日志可查)
}
a.holder, a.fn = pluginID, fn
}
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
a.mu.Lock()
fn := a.fn
a.mu.Unlock()
if fn == nil || owner == "" || projectID == "" {
return false, nil
}
return fn(owner, projectID)
}
// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面
// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与
// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。
func wireScopedHostFactory(caps hostcaps) {
extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host {
return inprocHost{caps: caps, pluginID: pluginID, owner: owner}
})
}
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
@@ -39,6 +79,21 @@ func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, e
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
}
// maxParseInput 与上传端点的读取上限同口径(server.upload 的 100<<20)。
const maxParseInput = 100 << 20
// parse 走基座内置解析器(EXT-5):纯计算、不落 owner 数据;不可解析由
// Warnings 显式表达,只有取消与超限是 error。
func (c hostcaps) parse(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
if err := ctx.Err(); err != nil {
return core.ParsedDocument{}, err
}
if len(content) > maxParseInput {
return core.ParsedDocument{}, fmt.Errorf("parse input %d bytes exceeds %d limit", len(content), maxParseInput)
}
return ingest.Parse(filename, mime, content), nil
}
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
type inprocHost struct {
@@ -75,7 +130,7 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
//(embedding 失败降级纯全文,不阻断写入)。
// (embedding 失败降级纯全文,不阻断写入)。
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
@@ -93,6 +148,44 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
}
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
}
refs := map[string]bool{}
for i := range docs {
docs[i].Owner = core.ID(h.owner)
if docs[i].Source == "" {
docs[i].Source = "plugin:" + h.pluginID
}
if docs[i].Ref != "" {
refs[docs[i].Ref] = true
}
}
for ref := range refs {
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
return err
}
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
return err
}
}
return h.KnowledgeWrite(ctx, docs, embed)
}
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
if sourcePrefix != "plugin:"+h.pluginID {
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
}
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
return err
}
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
@@ -114,3 +207,9 @@ func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
return h.caps.st.ArtifactContent(h.owner, artifactID)
}
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
return h.caps.parse(ctx, filename, mime, content)
}
+111
View File
@@ -1,6 +1,8 @@
package app
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"path/filepath"
@@ -193,3 +195,112 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
t.Fatalf("vector owner leak: %+v", bhits)
}
}
// TestInprocHostParseDocumentOwnerScope:EXT-5 解析方法的 owner 作用域——
// alice 经句柄读自己的工件并解析;bob 用同一工件 ID 在 ArtifactContent 处
// 即被拒(解析是纯计算,作用域边界在取原件一环)。
func TestInprocHostParseDocumentOwnerScope(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{
ID: "tst",
Register: func(reg extension.Registry) error {
return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.parse","title":"解析"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
id, _ := req.Params["artifactId"].(string)
content, err := h.ArtifactContent(id)
if err != nil {
return nil, err
}
doc, err := h.ParseDocument(ctx, "报价.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content)
if err != nil {
return nil, err
}
return map[string]any{
"parser": doc.Meta.Parser,
"warnings": len(doc.Warnings),
"blocks": len(doc.Blocks),
}, nil
})
},
})
bus, st := newInprocTestApp(t, nil)
art := core.Artifact{ProjectID: "p1", Owner: "alice", Name: "报价.xlsx", Kind: "document"}
if err := st.CreateArtifact(&art, testXLSXBytes(t)); err != nil {
t.Fatal(err)
}
artID := art.ID
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "alice", Entrance: "chat",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("alice dispatch: %+v %s", resp.Receipt, resp.Receipt.Reason)
}
m, _ := resp.Payload.(map[string]any)
if m["parser"] != "builtin:xlsx" || m["warnings"] != 0 || m["blocks"] == 0 {
t.Fatalf("alice payload: %+v", m)
}
// bob 同一工件 ID:取原件即被拒(跨 owner 不可见)。
resp = bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "bob", Entrance: "chat",
})
if resp.Receipt.State == agentcommand.StateCommitted {
t.Fatalf("bob must not read alice's artifact: %+v", resp.Payload)
}
}
// TestInprocHostParseDocumentLimits:仅有的两条 error 路径——ctx 取消与输入
// 超过 100MiB 上限;不可解析格式仍是带 Warnings 的显式失败。
func TestInprocHostParseDocumentLimits(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "alice"}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := h.ParseDocument(ctx, "a.xlsx", "", []byte("x")); err == nil {
t.Fatal("canceled ctx must error")
}
if _, err := h.ParseDocument(context.Background(), "a.xlsx", "", make([]byte, maxParseInput+1)); err == nil {
t.Fatal("oversized input must error")
}
doc, err := h.ParseDocument(context.Background(), "旧.xls", "", []byte{0xD0, 0xCF})
if err != nil {
t.Fatalf("legacy format is a visible failure, not an error: %v", err)
}
if doc.Meta.Parser != "builtin:legacy-office" || len(doc.Warnings) != 1 {
t.Fatalf("legacy doc: %+v %v", doc.Meta, doc.Warnings)
}
}
// testXLSXBytes 构造最小可用 xlsx(单表两行),供句柄测试解析。
func testXLSXBytes(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
files := map[string]string{
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价" sheetId="1" r:id="rId1"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>物料</t></is></c><c r="B1" t="inlineStr"><is><t>单价</t></is></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></c><c r="B2"><v>12.5</v></c></row>
</sheetData></worksheet>`,
}
for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(files[name]))
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"testing"
"agent-system-backend/internal/settings"
core "agent-system-core"
)
// 慢通道开关默认关(行为与 I1 一致的机器证明):
// 两条 runtime.intentSemantic / runtime.intentGenerator 必须注册且默认 false。
func TestIntentSlowLaneFlagsDefaultOff(t *testing.T) {
seen := map[string]core.SettingDesc{}
for _, d := range settings.Schema() {
seen[d.Key] = d
}
for _, key := range []string{"runtime.intentSemantic", "runtime.intentGenerator"} {
d, ok := seen[key]
if !ok {
t.Fatalf("设置未注册:%s", key)
}
if d.Type != core.SettingBool {
t.Fatalf("%s 类型应为 bool,得到 %v", key, d.Type)
}
if def, isBool := d.Default.(bool); !isBool || def {
t.Fatalf("%s 必须默认 false,得到 %v", key, d.Default)
}
}
}
// 遥测开关默认安全(N3 WP4):otlpEndpoint 默认空(不导出,保持现状),
// serviceName 有非空默认。两处都注册进 Schema。
func TestTelemetryFlagsDefaults(t *testing.T) {
seen := map[string]core.SettingDesc{}
for _, d := range settings.Schema() {
seen[d.Key] = d
}
if d, ok := seen["telemetry.otlpEndpoint"]; !ok || d.Default != "" {
t.Fatalf("otlpEndpoint 必须注册且默认空:%+v", d)
}
if d, ok := seen["telemetry.serviceName"]; !ok || d.Default != "agent-system-backend" {
t.Fatalf("serviceName 必须注册且默认 agent-system-backend:%+v", d)
}
}
+57
View File
@@ -0,0 +1,57 @@
package app
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// EXT-11 红测:注入前 NewScopedHost fail-closed;注入后重建句柄与分发期同构
// (owner 作用域、PluginID、KV 隔离、知识删除前缀强制)。
func TestScopedHostFactory(t *testing.T) {
// 注入前:fail-closed。
if _, err := extension.NewScopedHost("alice", "rdm"); err == nil {
t.Fatal("before wiring, NewScopedHost must fail closed(工厂未注入)")
}
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
caps := hostcaps{st: st, pipe: pipe, set: set}
// 组合根同款注入(app.Run 内以 wireScopedHostFactory 接线;此处直接调内部函数)。
wireScopedHostFactory(caps)
t.Cleanup(func() { extension.SetScopedHostFactory(nil) })
h, err := extension.NewScopedHost("alice", "rdm")
if err != nil {
t.Fatalf("NewScopedHost: %v", err)
}
if h.Owner() != "alice" || h.PluginID() != "rdm" {
t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID())
}
// KV 往返落 owner 作用域。
if err := h.KVPut("k1", "v1"); err != nil {
t.Fatal(err)
}
if v, ok, err := h.KVGet("k1"); err != nil || !ok || v != "v1" {
t.Fatalf("KVGet = %q ok=%v err=%v", v, ok, err)
}
// 同构性:知识删除前缀强制(manual 不可及)。
if err := h.KnowledgeDelete(context.Background(), "manual"); err == nil {
t.Fatal("scoped host must keep the plugin:<id> prefix enforcement")
}
// 空 owner/pluginID:拒绝。
if _, err := extension.NewScopedHost("", "rdm"); err == nil {
t.Fatal("empty owner must be rejected")
}
}
+9 -1
View File
@@ -29,13 +29,16 @@ const cardAppendWindow = 250 * time.Millisecond
type Service struct {
st *store.Store
emit Emitter
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
MirrorConvCards bool
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
}
func New(st *store.Store, emit Emitter) *Service {
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}}
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
}
// emitCard 按卡归属发布卡片事件(D50):
@@ -52,6 +55,11 @@ func (s *Service) emitCard(c core.ActivityCard, typ core.StreamEventType, payloa
if err := s.emit(c.ConversationID, c.SourceRunID, typ, payload); err != nil {
return err
}
// EXT-12 最小开关:会话卡不镜像进项目公共流(不发布即不持久,
// 重放天然不含会话卡);项目级卡(无会话归属)不受影响。
if !s.MirrorConvCards {
return nil
}
}
if c.ProjectID != "" {
return s.emit(string(core.ProjectChannelKey(c.ProjectID)), c.SourceRunID, typ, payload)
+56
View File
@@ -0,0 +1,56 @@
package cardsvc
import (
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// EXT-12 最小开关(红测):MirrorConvCards=false 时会话卡不镜像进项目公共流
// (不发布即不持久,重放天然不含会话卡);项目级卡不受影响;缺省 true 保持现状。
func TestConvCardMirrorSwitch(t *testing.T) {
newSvc := func(mirror bool) (*Service, *[]string) {
chans := []string{}
emit := func(convID, runID string, typ core.StreamEventType, payload any) error {
chans = append(chans, convID)
return nil
}
st, err := store.OpenSQLite(t.TempDir() + "/cards.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
svc := New(st, emit)
svc.MirrorConvCards = mirror
return svc, &chans
}
card := core.ActivityCard{ConversationID: "c-priv", ProjectID: "p-1", Kind: core.CardKindProgress, Title: "私有过程卡"}
// 开(缺省现状):会话流 + 项目流双发。
svc, chans := newSvc(true)
if _, err := svc.Create("alice", card, ""); err != nil {
t.Fatal(err)
}
if len(*chans) != 2 {
t.Fatalf("mirror on: emitted %v, want 会话流+项目流", *chans)
}
// 关:只进本人会话流,项目公共流零事件。
svc2, chans2 := newSvc(false)
if _, err := svc2.Create("alice", card, ""); err != nil {
t.Fatal(err)
}
if len(*chans2) != 1 || (*chans2)[0] != "c-priv" {
t.Fatalf("mirror off: emitted %v, want 仅会话流 c-priv(项目流不得发布)", *chans2)
}
// 项目级卡(无会话归属)不受开关影响。
pc := core.ActivityCard{ProjectID: "p-1", Kind: core.CardKindProgress, Title: "项目级卡"}
if _, err := svc2.Create("alice", pc, ""); err != nil {
t.Fatal(err)
}
if len(*chans2) != 2 || (*chans2)[1] != string(core.ProjectChannelKey("p-1")) {
t.Fatalf("project-level card must still publish: %v", *chans2)
}
}
+5 -3
View File
@@ -18,9 +18,11 @@ import (
"agent-system-core"
)
// AuditStore 审计落盘端口(store 实现;v7 command_audit)。
// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。
// owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤,
// 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。
type AuditStore interface {
AppendAudit(env agentcommand.AuditEnvelope) error
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
}
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
@@ -275,7 +277,7 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
if _, src, err := b.reg.Resolve(req.ID); err == nil {
env.Source = src
}
if err := b.audit.AppendAudit(env); err != nil {
if err := b.audit.AppendAudit(req.Owner, env); err != nil {
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
}
+1 -1
View File
@@ -20,7 +20,7 @@ type memAudit struct {
fail bool
}
func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error {
func (m *memAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
if m.fail {
return errors.New("audit down")
}
+21 -12
View File
@@ -26,6 +26,8 @@ type Config struct {
Instance string `json:"instance"`
InsecureListen bool `json:"insecureListen"`
AllowedHostsRaw string `json:"allowedHosts"`
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
DisableExecTools bool `json:"disableExecTools"`
}
var defaults = Config{
@@ -37,16 +39,17 @@ 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",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"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",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
}
// Source 配置来源层级(数值越大优先级越高)。
@@ -165,6 +168,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.InsecureListen
case "allowedHosts":
dst = &c.AllowedHostsRaw
case "disableExecTools":
dst = &c.DisableExecTools
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -199,13 +204,15 @@ func stringField(c Config, f string) string {
}
func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen"
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
return c.InsecureListen
case "disableExecTools":
return c.DisableExecTools
default:
return c.Mock
}
@@ -215,6 +222,8 @@ func setBool(c *Config, f string, v bool) {
switch f {
case "insecureListen":
c.InsecureListen = v
case "disableExecTools":
c.DisableExecTools = v
default:
c.Mock = v
}
@@ -259,7 +268,7 @@ 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"} {
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools"} {
v := stringField(r.Config, f)
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
+32
View File
@@ -123,6 +123,38 @@ func TestEnvBool(t *testing.T) {
}
}
// disableExecTools:默认 false;文件、环境、CLI 三层均可打开,CLI 优先。
func TestDisableExecToolsLayers(t *testing.T) {
r, err := Load("", noEnv, Config{}, nil)
if err != nil || r.DisableExecTools {
t.Fatalf("default must be false: %v %+v", err, r.Config)
}
f := fileWith(t, `{"disableExecTools":true}`)
r, err = Load(f, noEnv, Config{}, nil)
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcFile {
t.Fatalf("file layer: %v %+v src=%v", err, r.Config, r.SourceOf("disableExecTools"))
}
env := func(k string) (string, bool) {
if k == "AGENT_SYSTEM_DISABLE_EXEC_TOOLS" {
return "1", true
}
return "", false
}
r, err = Load("", env, Config{}, nil)
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcEnv {
t.Fatalf("env layer: %v %+v", err, r.Config)
}
r, err = Load("", env, Config{DisableExecTools: false}, map[string]bool{"disableExecTools": true})
if err != nil || r.DisableExecTools || r.SourceOf("disableExecTools") != SrcCLI {
t.Fatalf("cli must win: %v %+v", err, r.Config)
}
var out bytes.Buffer
r.Explain(&out, "")
if !bytes.Contains(out.Bytes(), []byte("disableExecTools")) {
t.Fatalf("explain must list disableExecTools:\n%s", out.String())
}
}
// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。
func TestExplainRedactsDSN(t *testing.T) {
env := func(k string) (string, bool) {
+30 -8
View File
@@ -207,12 +207,12 @@ func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []age
}
dropped := 0
for estimateAll(msgs) > limit {
idx := oldestDrop(msgs)
if idx < 0 {
start, end := oldestDropRange(msgs)
if start < 0 {
break
}
msgs = append(msgs[:idx], msgs[idx+1:]...)
dropped++
dropped += end - start
msgs = append(msgs[:start], msgs[end:]...)
}
if dropped > 0 {
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
@@ -226,9 +226,14 @@ func fmtDropped(n int) string {
func itoa(n int) string { return strconv.Itoa(n) }
func oldestDrop(msgs []agentllm.Message) int {
// oldestDropRange 返回下一次丢弃的区间 [start, end)(找不到返回 -1,-1)。
// 丢弃按工具组对齐:带 tool_calls 的 assistant 与其连续 tool 结果同退,
// 开头的孤立 tool 结果组整组同退——裁切后既不剩无结果的调用,
// 也不剩无调用的结果(与库层 alignToolTail 同语义、方向相反)。
// 组越过最后一条(当前轮)时不再丢弃:窗口允许略超,配对不可破坏。
func oldestDropRange(msgs []agentllm.Message) (int, int) {
if len(msgs) < 3 {
return -1
return -1, -1
}
// 保留开头的系统前缀和最后一条(当前轮)。
start := 0
@@ -236,9 +241,26 @@ func oldestDrop(msgs []agentllm.Message) int {
start++
}
if start >= len(msgs)-1 {
return -1
return -1, -1
}
return start
end := start + 1
m := msgs[start]
switch {
case m.Role == agentllm.RoleAssistant && len(m.ToolCalls) > 0:
// assistant 及其连续 tool 结果整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
case m.Role == agentllm.RoleTool:
// 开头即孤立 tool 结果(其 assistant 必已被丢或不存在):整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
}
if end > len(msgs)-1 {
return -1, -1
}
return start, end
}
func estimateAll(msgs []agentllm.Message) int {
+115
View File
@@ -62,3 +62,118 @@ func TestTrimDropsOldestHistory(t *testing.T) {
t.Fatal("expected truncation")
}
}
// ---- N2 WP1:裁切工具组对齐(验收② 产品侧机器证据)----
// assertNoOrphanToolPairs 双向断言:每个 tool_call 有结果,每个结果有调用。
func assertNoOrphanToolPairs(t *testing.T, msgs []agentllm.Message) {
t.Helper()
calls := map[string]bool{}
results := map[string]bool{}
for _, m := range msgs {
for _, c := range m.ToolCalls {
calls[c.ID] = true
}
if m.Role == agentllm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
}
}
for id := range calls {
if !results[id] {
t.Fatalf("孤立 tool_call:%s(无结果)", id)
}
}
for id := range results {
if !calls[id] {
t.Fatalf("悬空 tool 结果:%s(无调用)", id)
}
}
}
func toolMsgs() []agentllm.Message {
call := func(id, name string) agentllm.ToolCall {
return agentllm.ToolCall{ID: id, Name: name, Arguments: "{}"}
}
return []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧一", 300)},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc1", "read")}},
{Role: agentllm.RoleTool, ToolCallID: "tc1", Name: "read", Content: strings.Repeat("结果一", 200)},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧二", 300)},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc2", "grep"), call("tc3", "read")}},
{Role: agentllm.RoleTool, ToolCallID: "tc2", Name: "grep", Content: strings.Repeat("结果二", 200)},
{Role: agentllm.RoleTool, ToolCallID: "tc3", Name: "read", Content: strings.Repeat("结果三", 200)},
{Role: agentllm.RoleUser, Content: "现在"},
}
}
// 连续多轮裁切后配对仍成立:assistant 与其结果同退,不逐条拆散。
func TestTrimOldestKeepsToolPairs(t *testing.T) {
out, rep := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 1600})
assertNoOrphanToolPairs(t, out)
if len(rep.Truncations) == 0 {
t.Fatal("expected truncation")
}
if out[len(out)-1].Content != "现在" {
t.Fatal("当前轮必须保留")
}
// 窗口再压:旧二组也须整组同退,仍不出现孤立配对。
out2, _ := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 900})
assertNoOrphanToolPairs(t, out2)
}
// 开头即孤立 tool 结果组:整组同退(其 assistant 已不存在)。
func TestTrimOldestDropsLeadingOrphanToolRun(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleTool, ToolCallID: "ghost", Name: "read", Content: strings.Repeat("孤", 500)},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧", 500)},
{Role: agentllm.RoleUser, Content: "现在"},
}
out, _ := FinishAssembly(msgs, &Report{}, Tail{Window: 700})
assertNoOrphanToolPairs(t, out)
for _, m := range out {
if m.ToolCallID == "ghost" {
t.Fatal("孤立结果组应整组丢弃")
}
}
}
// 工具组越过当前轮时不再丢弃:窗口可略超,配对不可破坏。
func TestTrimOldestStopsWhenGroupCrossesCurrentTurn(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{{ID: "tc9", Name: "read", Arguments: "{}"}}},
{Role: agentllm.RoleTool, ToolCallID: "tc9", Name: "read", Content: strings.Repeat("结果", 900)},
}
start, end := oldestDropRange(msgs)
if start != -1 || end != -1 {
t.Fatalf("组越过当前轮应返回 -1,-1,得到 %d,%d", start, end)
}
}
// ---- N2 WP1:PrefixHash 稳定契约(同一输入 → 同一前缀哈希)----
func TestPrefixHashStableForIdenticalInput(t *testing.T) {
build := func() []agentllm.Message {
return []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "系统前缀A"},
{Role: agentllm.RoleSystem, Content: "系统前缀B"},
{Role: agentllm.RoleUser, Content: "历史一"},
{Role: agentllm.RoleAssistant, Content: "回复一"},
{Role: agentllm.RoleUser, Content: "现在"},
}
}
_, rep1 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
_, rep2 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
if rep1.PrefixHash == "" || rep1.PrefixHash != rep2.PrefixHash {
t.Fatalf("同一输入的前缀哈希必须一致:%q vs %q", rep1.PrefixHash, rep2.PrefixHash)
}
// 前缀变化必须改变哈希(钉住哈希语义,防退化为常量)。
other := build()
other[0].Content = "系统前缀X"
_, rep3 := FinishAssembly(other, &Report{}, Tail{Instructions: "个人指令"})
if rep3.PrefixHash == rep1.PrefixHash {
t.Fatal("前缀变化后哈希必须不同")
}
}
+5
View File
@@ -0,0 +1,5 @@
package hostapi
import "encoding/json"
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
+10 -2
View File
@@ -18,7 +18,10 @@ type Server struct {
Store *store.Store
Dispatch func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error
Search func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
Complete func(ctx context.Context, owner, prompt string) (string, error)
// SharedSearch EXT-8 余项:非 nil 时 search 端点改用之(组合根传入
// 「本人结果 + 适配器放行的共享追加」组合;nil=现状仅本人)。
SharedSearch func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
Complete func(ctx context.Context, owner, prompt string) (string, error)
}
func (s *Server) Handler() http.Handler {
@@ -143,7 +146,12 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
http.Error(w, "retriever unavailable", http.StatusServiceUnavailable)
return
}
hits, err := s.Search(r.Context(), s.owner(r), r.URL.Query().Get("q"))
// EXT-8 余项:共享版可用时整体切换(本人+共享追加)。
fn := s.Search
if s.SharedSearch != nil {
fn = s.SharedSearch
}
hits, err := fn(r.Context(), s.owner(r), r.URL.Query().Get("q"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
+49
View File
@@ -0,0 +1,49 @@
package hostapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
core "agent-system-core"
)
// EXT-8 余项:hostapi 检索面——SharedSearch 可用时整体切换(本人+适配器放行的
// 共享追加);不可用时维持现状(仅本人回调)。
func TestSharedSearchFace(t *testing.T) {
own := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Owner: "bob", Chunk: "本人命中"}}}
sharedHit := core.RetrievalHit{Doc: core.KnowledgeDoc{Owner: "alice", ProjectID: "prj-s", Chunk: "共享命中"}}
srv := &Server{Token: "t", Search: func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return own, nil
}}
run := func() int {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
req.Header.Set("Authorization", "Bearer "+srv.Token)
srv.Handler().ServeHTTP(rec, req)
return rec.Code
}
if code := run(); code != http.StatusOK {
t.Fatalf("plain search: %d", code)
}
srv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return append(own, sharedHit), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
req.Header.Set("Authorization", "Bearer "+srv.Token)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("shared search: %d %s", rec.Code, rec.Body.String())
}
var hits []core.RetrievalHit
if err := jsonUnmarshal(rec.Body.Bytes(), &hits); err != nil {
t.Fatal(err)
}
if len(hits) != 2 || hits[1].Doc.Owner != "alice" {
t.Fatalf("hits = %+v, want 本人+共享", hits)
}
}
+171 -60
View File
@@ -4,7 +4,6 @@
package ingest
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
@@ -34,11 +33,16 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
doc.Meta.Confidence = 1.0
case "docx":
doc.Meta.Parser = "builtin:docx"
blocks, err := parseDocx(content)
if err != nil {
blocks, revised, err := parseDocxRevised(content)
switch {
case err != nil:
doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error())
doc.Meta.Confidence = 0.3
} else {
case revised:
doc.Blocks = blocks
doc.Warnings = append(doc.Warnings, docxRevisionWarning)
doc.Meta.Confidence = 0.85
default:
doc.Blocks = blocks
doc.Meta.Confidence = 0.92
}
@@ -52,6 +56,35 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
doc.Blocks = blocks
doc.Meta.Confidence = 0.85
}
case "xlsx", "xlsm":
doc.Meta.Parser = "builtin:xlsx"
blocks, warns := parseXlsx(content)
doc.Blocks = blocks
doc.Warnings = append(doc.Warnings, warns...)
switch {
case len(blocks) == 0:
doc.Meta.Confidence = 0.3
case len(warns) > 0:
doc.Meta.Confidence = 0.6 // 超限截断:内容可用但注明不完整
default:
doc.Meta.Confidence = 0.9
}
case "pptx", "pptm", "ppsx":
doc.Meta.Parser = "builtin:pptx"
blocks, warns := parsePptx(content)
doc.Blocks = blocks
doc.Warnings = append(doc.Warnings, warns...)
switch {
case len(blocks) == 0:
doc.Meta.Confidence = 0.3
case len(warns) > 0:
doc.Meta.Confidence = 0.6
default:
doc.Meta.Confidence = 0.9
}
case "doc", "xls", "ppt":
doc.Meta.Parser = "builtin:legacy-office"
doc.Warnings = append(doc.Warnings, "旧版二进制格式 ."+ext+" 不解析:请转存为 ."+legacyOfficeTarget(ext)+" 后重传")
default:
if isImage(ext) {
doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)")
@@ -133,22 +166,73 @@ func parsePlain(s string) []core.DocBlock {
// ---- docx(OOXML:zip 内 word/document.xml)----
// xpar 是解码后的段落。文字按文档顺序从任意深度的 w:t 收集,w:ins、
// w:hyperlink、w:fldSimple、w:sdt 等包裹层内的文字因此不会丢;被删除或
// 移走的原文(w:del、w:moveFrom)、域代码(w:instrText)与兼容回退分支
// (mc:Fallback,与 mc:Choice 内容重复)不进正文。
type xpar struct {
Props struct {
Style struct {
Val string `xml:"val,attr"`
} `xml:"pStyle"`
} `xml:"pPr"`
Runs []xrun `xml:"r"`
Style string
Text string
Revised bool // 段内含修订标记(插入/删除/移动)
}
type xrun struct {
Text []struct {
Content string `xml:",chardata"`
} `xml:"t"`
Break struct {
Val string `xml:"val,attr"`
} `xml:"br"`
// docxSkipText 子树里的文字不属于当前正文。
var docxSkipText = map[string]bool{
"del": true, "moveFrom": true, "instrText": true, "delInstrText": true, "Fallback": true,
}
// docxRevision 出现即说明文档带修订。
var docxRevision = map[string]bool{"ins": true, "del": true, "moveFrom": true, "moveTo": true}
// UnmarshalXML 按 token 流遍历段落子树。按固定层级(p/r/t)映射只认 w:p
// 的直接子级 w:r,修订插入、超链接与域结果里的文字会被静默跳过。
func (p *xpar) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var sb strings.Builder
skip := 0
for {
tok, err := d.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
name := t.Name.Local
if docxRevision[name] {
p.Revised = true
}
switch {
case docxSkipText[name]:
skip++
case skip > 0:
case name == "pStyle" && p.Style == "":
p.Style = attrAnyNS(t, "val")
case name == "t":
var s string
if err := d.DecodeElement(&s, &t); err != nil {
return err
}
sb.WriteString(s)
}
case xml.EndElement:
if docxSkipText[t.Name.Local] && skip > 0 {
skip--
}
if t.Name == start.Name {
p.Text = sb.String()
return nil
}
}
}
}
// attrAnyNS 按本地名取属性值(w:val 与无前缀 val 都算)。
func attrAnyNS(st xml.StartElement, name string) string {
for _, a := range st.Attr {
if a.Name.Local == name {
return a.Value
}
}
return ""
}
type xtbl struct {
@@ -164,97 +248,108 @@ type xtc struct {
}
func parseDocx(content []byte) ([]core.DocBlock, error) {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
blocks, _, err := parseDocxRevised(content)
return blocks, err
}
// parseDocxRevised 同 parseDocx,另报告文档是否带修订标记。
func parseDocxRevised(content []byte) ([]core.DocBlock, bool, error) {
zr, err := openZip(content)
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
return nil, false, fmt.Errorf("open zip: %w", err)
}
var docXML []byte
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, err := f.Open()
if err != nil {
return nil, err
}
docXML, _ = io.ReadAll(rc)
rc.Close()
break
}
}
if docXML == nil {
return nil, fmt.Errorf("word/document.xml not found")
docXML, err := zipPart(zr, "word/document.xml")
if err != nil {
return nil, false, err
}
return parseDocxBody(docXML)
}
// docxRevisionWarning 文档带修订时的显式告警(SR-A5:不静默)。
const docxRevisionWarning = "docx 含修订标记:正文按「接受全部修订」提取(插入保留、删除不计入);审阅修订过程请查看原件"
// parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地
// DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。
func parseDocxBody(docXML []byte) ([]core.DocBlock, error) {
// 段落可能包在 w:ins、w:sdt 等容器里,所以任意深度的 p 与 tbl 都识别;
// 返回值 revised 报告是否遇到修订标记。
func parseDocxBody(docXML []byte) (blocks []core.DocBlock, revised bool, err error) {
dec := xml.NewDecoder(bytes.NewReader(docXML))
var blocks []core.DocBlock
skip := 0
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("decode document.xml: %w", err)
return nil, revised, fmt.Errorf("decode document.xml: %w", err)
}
if end, ok := tok.(xml.EndElement); ok {
if docxSkipText[end.Name.Local] && skip > 0 {
skip--
}
continue
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch start.Name.Local {
name := start.Name.Local
if docxRevision[name] {
revised = true
}
if docxSkipText[name] {
skip++
continue
}
if skip > 0 {
continue
}
switch name {
case "p":
var p xpar
if err := dec.DecodeElement(&p, &start); err != nil {
return nil, fmt.Errorf("decode paragraph: %w", err)
return nil, revised, fmt.Errorf("decode paragraph: %w", err)
}
text := paragraphText(p)
if strings.TrimSpace(text) == "" {
revised = revised || p.Revised
if strings.TrimSpace(p.Text) == "" {
continue
}
if lvl, ok := headingLevel(p.Props.Style.Val); ok {
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
if lvl, ok := headingLevel(p.Style); ok {
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: p.Text})
} else {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: p.Text})
}
case "tbl":
var tbl xtbl
if err := dec.DecodeElement(&tbl, &start); err != nil {
return nil, fmt.Errorf("decode table: %w", err)
return nil, revised, fmt.Errorf("decode table: %w", err)
}
if rows := tableRows(tbl); len(rows) > 0 {
rows, tblRevised := tableRows(tbl)
revised = revised || tblRevised
if len(rows) > 0 {
blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
}
}
}
return blocks, nil
return blocks, revised, nil
}
func tableRows(tbl xtbl) [][]string {
func tableRows(tbl xtbl) ([][]string, bool) {
var rows [][]string
revised := false
for _, tr := range tbl.Rows {
row := []string{}
for _, tc := range tr.Cells {
cell := []string{}
for _, p := range tc.Paragraphs {
cell = append(cell, paragraphText(p))
revised = revised || p.Revised
cell = append(cell, p.Text)
}
row = append(row, strings.Join(cell, " "))
}
rows = append(rows, row)
}
return rows
}
func paragraphText(p xpar) string {
var sb strings.Builder
for _, r := range p.Runs {
for _, t := range r.Text {
sb.WriteString(t.Content)
}
}
return sb.String()
return rows, revised
}
func headingLevel(style string) (int, bool) {
@@ -322,13 +417,29 @@ func isMostlyPrintable(s string) bool {
return nonPrint*10 < len(s)
}
// legacyOfficeTarget 旧版二进制 Office 格式的现代等价格式(提示转存用)。
func legacyOfficeTarget(ext string) string {
switch ext {
case "doc":
return "docx"
case "xls":
return "xlsx"
case "ppt":
return "pptx"
}
return ext
}
// ParsersDescription 返回解析器注册表描述(来源标记;设置页/文档用)。
func ParsersDescription() []core.ParserDesc {
return []core.ParserDesc{
{ID: "builtin:md", Formats: []string{"md", "markdown"}, Source: core.SourceBuiltin},
{ID: "builtin:txt", Formats: []string{"txt", "csv", "log"}, Source: core.SourceBuiltin},
{ID: "builtin:txt", Formats: []string{"txt", "text", "csv", "log"}, Source: core.SourceBuiltin},
{ID: "builtin:docx", Formats: []string{"docx"}, Source: core.SourceBuiltin},
{ID: "builtin:pdf", Formats: []string{"pdf"}, Source: core.SourceBuiltin, Note: "文本层;扫描件为远期 MinerU 深度档"},
{ID: "builtin:xlsx", Formats: []string{"xlsx", "xlsm"}, Source: core.SourceBuiltin},
{ID: "builtin:pptx", Formats: []string{"pptx", "pptm", "ppsx"}, Source: core.SourceBuiltin},
{ID: "builtin:legacy-office", Formats: []string{"doc", "xls", "ppt"}, Source: core.SourceBuiltin, Note: "不解析;提示转存现代格式后重传"},
{ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"},
}
}
+62
View File
@@ -112,6 +112,68 @@ func TestParseDocx(t *testing.T) {
}
}
// docxWith 把 body 片段包成最小 docx(zip 内仅 word/document.xml)。
func docxWith(t *testing.T, body string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
f.Write([]byte(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>` + body + `</w:body></w:document>`))
zw.Close()
return buf.Bytes()
}
// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文;
// 含修订时显式告警(SR-A5:禁止静默降级)。
func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) {
body := `<w:p><w:r><w:t>周期</w:t></w:r><w:ins w:id="1" w:author="a"><w:r><w:t>18</w:t></w:r></w:ins>` +
`<w:del w:id="2" w:author="a"><w:r><w:delText>12</w:delText></w:r></w:del><w:r><w:t>个月</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>见</w:t></w:r><w:hyperlink r:id="rId5"><w:r><w:t>附件A</w:t></w:r></w:hyperlink></w:p>` +
`<w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t>页</w:t></w:r></w:p>` +
`<w:tbl><w:tr><w:tc><w:p><w:ins w:id="3" w:author="a"><w:r><w:t>新增项</w:t></w:r></w:ins></w:p></w:tc></w:tr></w:tbl>`
doc := Parse("修订稿.docx", "", docxWith(t, body))
var paras []string
var cell string
for _, b := range doc.Blocks {
switch b.Type {
case core.BlockPara:
paras = append(paras, b.Text)
case core.BlockTable:
if len(b.Rows) > 0 && len(b.Rows[0]) > 0 {
cell = b.Rows[0][0]
}
}
}
want := []string{"周期18个月", "见附件A", "3页"}
if strings.Join(paras, "|") != strings.Join(want, "|") {
t.Errorf("paragraphs: got %q want %q", paras, want)
}
if cell != "新增项" {
t.Errorf("table cell inside w:ins: got %q", cell)
}
if strings.Contains(doc.PlainText(), "12个月") {
t.Errorf("deleted text leaked into body: %q", doc.PlainText())
}
warned := false
for _, w := range doc.Warnings {
warned = warned || strings.Contains(w, "修订")
}
if !warned {
t.Errorf("tracked changes must be reported in warnings, got %v", doc.Warnings)
}
}
// 无修订的普通文档不产生修订告警(避免噪音)。
func TestParseDocxNoRevisionNoWarning(t *testing.T) {
doc := Parse("干净.docx", "", docxWith(t, `<w:p><w:r><w:t>正文</w:t></w:r></w:p>`))
if len(doc.Warnings) != 0 {
t.Errorf("clean docx warnings: %v", doc.Warnings)
}
}
func TestParsePDFInvalid(t *testing.T) {
doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf"))
if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 {
+140
View File
@@ -0,0 +1,140 @@
package ingest
// OOXML 公共件:zip 部件读取(解压上限防 zip 炸弹)、关系部件解析、
// r:id 属性与相对路径归一。docx/xlsx/pptx 解析器共用;纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"strings"
)
// ooxmlPartLimit 单个 zip 部件的解压上限(测试可覆盖)。
var ooxmlPartLimit int64 = 64 << 20
func openZip(content []byte) (*zip.Reader, error) {
return zip.NewReader(bytes.NewReader(content), int64(len(content)))
}
// zipPart 按名称读取部件;解压超过 ooxmlPartLimit 视为异常拒绝。
func zipPart(zr *zip.Reader, name string) ([]byte, error) {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
b, err := io.ReadAll(io.LimitReader(rc, ooxmlPartLimit+1))
rc.Close()
if err != nil {
return nil, err
}
if int64(len(b)) > ooxmlPartLimit {
return nil, fmt.Errorf("part %s exceeds %d bytes decompression limit", name, ooxmlPartLimit)
}
return b, nil
}
return nil, fmt.Errorf("part %s not found", name)
}
// relsPath 计算部件对应的关系部件名(xl/workbook.xml → xl/_rels/workbook.xml.rels)。
func relsPath(part string) string {
dir, base := path.Split(part)
return dir + "_rels/" + base + ".rels"
}
type ooxmlRel struct {
ID, Target, Type string
}
// parseRels 解析部件的关系表;无关系部件返回空表(不是错误)。
func parseRels(zr *zip.Reader, part string) (map[string]ooxmlRel, error) {
data, err := zipPart(zr, relsPath(part))
if err != nil {
return map[string]ooxmlRel{}, nil
}
dec := xml.NewDecoder(bytes.NewReader(data))
out := map[string]ooxmlRel{}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "Relationship" {
continue
}
r := ooxmlRel{}
for _, a := range st.Attr {
switch a.Name.Local {
case "Id":
r.ID = a.Value
case "Target":
r.Target = a.Value
case "Type":
r.Type = a.Value
}
}
if r.ID != "" {
out[r.ID] = r
}
}
return out, nil
}
// resolveRelTarget 把关系 target 解析为 zip 部件名:绝对 target(/xl/…)去
// 前导斜杠;相对 target 相对 partDir;归一化 ../ 与 ./ 段。
func resolveRelTarget(partDir, target string) string {
t := target
if !strings.HasPrefix(target, "/") {
t = partDir + "/" + target
}
var seg []string
for _, s := range strings.Split(t, "/") {
switch s {
case "", ".":
case "..":
if len(seg) > 0 {
seg = seg[:len(seg)-1]
}
default:
seg = append(seg, s)
}
}
return strings.Join(seg, "/")
}
// ridAttr 取 r:id:局部名 id 且带命名空间前缀(Transitional 与 Strict OOXML
// 的关系引用都满足该形态;无前缀的 id 是别的编号,不误取)。
func ridAttr(st xml.StartElement) string {
for _, a := range st.Attr {
if a.Name.Local == "id" && a.Name.Space != "" {
return a.Value
}
}
return ""
}
// attrLocal 取无命名空间前缀的属性值。
func attrLocal(st xml.StartElement, name string) string {
for _, a := range st.Attr {
if a.Name.Local == name && a.Name.Space == "" {
return a.Value
}
}
return ""
}
// relIsType 判断关系类型是否以标准关系类型后缀结尾(/worksheet、/slide 等)。
func relIsType(rel ooxmlRel, suffix string) bool {
return strings.HasSuffix(rel.Type, suffix)
}
+140
View File
@@ -0,0 +1,140 @@
package ingest
// 真实生成器轮测:openpyxl / python-pptx 现场生成 xlsx/pptx 再解析,防止
// 手工夹具只验证"自己写的 XML"。生成器缺失时 t.Skip(诚实出口);CI 已装
// 这两个包(.gitea/workflows/ci.yaml 安装 python-docx python-pptx openpyxl)。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
func pythonModuleAvailable(t *testing.T, module string) bool {
t.Helper()
if _, err := exec.LookPath("python3"); err != nil {
return false
}
return exec.Command("python3", "-c", "import "+module).Run() == nil
}
// runPythonScript 把脚本写到临时目录执行;失败即 t.Fatal(脚本属测试自身)。
func runPythonScript(t *testing.T, script string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "gen.py")
if err := os.WriteFile(path, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command("python3", path).CombinedOutput()
if err != nil {
t.Fatalf("python generator failed: %v\n%s", err, out)
}
}
func TestParseXlsxFromOpenpyxl(t *testing.T) {
if !pythonModuleAvailable(t, "openpyxl") {
t.Skip("python3 openpyxl not available")
}
dir := t.TempDir()
file := filepath.Join(dir, "报价单.xlsx")
runPythonScript(t, `
import openpyxl
from datetime import datetime
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "报价"
ws.append(["物料", "单价", "日期"])
ws.append(["支架", 12.5, datetime(2024, 1, 1)])
ws["C2"].number_format = "yyyy-mm-dd"
ws["D2"] = "=A2&\"-x\""
ws["D3"] = True
wb.create_sheet("说明").append(["备注行"])
wb.save(r"`+file+`")
`)
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
doc := Parse(file, "", data)
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heads []string
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if len(heads) != 2 || heads[0] != "报价" || heads[1] != "说明" {
t.Fatalf("headings: %v", heads)
}
plain := doc.PlainText()
for _, want := range []string{"物料", "支架", "12.5", "2024-01-01"} {
if !strings.Contains(plain, want) {
t.Errorf("missing %q in: %s", want, plain)
}
}
// openpyxl 公式不带缓存值:回显公式本身(可检索、不冒充结果)。
if !strings.Contains(plain, "=A2") {
t.Errorf("formula without cache should echo: %s", plain)
}
if !strings.Contains(plain, "TRUE") {
t.Errorf("boolean cell should render TRUE: %s", plain)
}
}
func TestParsePptxFromPythonPptx(t *testing.T) {
if !pythonModuleAvailable(t, "pptx") {
t.Skip("python3 pptx not available")
}
dir := t.TempDir()
file := filepath.Join(dir, "方案评审.pptx")
runPythonScript(t, `
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
s1 = prs.slides.add_slide(prs.slide_layouts[0])
s1.shapes.title.text = "评审结论"
s1.placeholders[1].text = "通过"
s2 = prs.slides.add_slide(prs.slide_layouts[5])
s2.shapes.title.text = "报价明细"
tbl = s2.shapes.add_table(2, 2, Inches(1), Inches(2), Inches(6), Inches(1)).table
tbl.cell(0, 0).text = "物料"
tbl.cell(0, 1).text = "单价"
tbl.cell(1, 0).text = "支架"
tbl.cell(1, 1).text = "12.5"
notes = s1.notes_slide.notes_text_frame
notes.text = "需要补充法规依据"
prs.save(r"`+file+`")
`)
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
doc := Parse(file, "", data)
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heads []string
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if len(heads) != 2 || !strings.HasPrefix(heads[0], "幻灯片 1:评审结论") || !strings.HasPrefix(heads[1], "幻灯片 2:报价明细") {
t.Fatalf("headings: %v", heads)
}
plain := doc.PlainText()
for _, want := range []string{"通过", "物料", "支架", "12.5", "备注:需要补充法规依据"} {
if !strings.Contains(plain, want) {
t.Errorf("missing %q in: %s", want, plain)
}
}
}
+432
View File
@@ -0,0 +1,432 @@
package ingest
// OOXML(xlsx/pptx)解析器测试:手工构造最小 zip 部件,覆盖顺序解析、富文本、
// 公式、日期、稀疏列、隐藏表、组合形状、AlternateContent、表格合并、备注、
// 上限与失败路径。真实生成器的轮测见 ooxml_python_test.go。
import (
"archive/zip"
"bytes"
"sort"
"strings"
"testing"
core "agent-system-core"
)
// zipBytes 按名称构造 zip(键排序保证确定性)。
func zipBytes(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
w, err := zw.Create(n)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(files[n])); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
const relTypeNS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
func xlsxFixture() map[string]string {
return map[string]string{
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="` + relTypeNS + `">
<sheets>
<sheet name="报价" sheetId="1" r:id="rId1"/>
<sheet name="草稿" sheetId="2" state="hidden" r:id="rId2"/>
</sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="` + relTypeNS + `/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="` + relTypeNS + `/worksheet" Target="/xl/worksheets/sheet2.xml"/>
</Relationships>`,
// shared[0]=物料;shared[1]=富文本"单价"(rPh 注音必须排除)。
"xl/sharedStrings.xml": `<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<si><t>物料</t></si>
<si><rPh sb="0" eb="2"><t>たんか</t></rPh><r><rPr lang="ja"/><t>单价</t></r></si>
</sst>`,
"xl/styles.xml": `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<numFmts><numFmt numFmtId="164" formatCode="yyyy&quot;年&quot;m&quot;月&quot;d&quot;日&quot;"/></numFmts>
<cellXfs count="3">
<xf numFmtId="0"/>
<xf numFmtId="14" applyNumberFormat="1"/>
<xf numFmtId="164" applyNumberFormat="1"/>
</cellXfs></styleSheet>`,
// A1=物料 B1=单价(富文本) A2=公式缓存 10 B2=无缓存公式 C2=日期(内置14)=2024-01-01
// D2=TRUE E2=#DIV/0! F2=内联 G2=自定义日期格式(2024-01-02) 稀疏行 C4=物料。
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>
<row r="2"><c r="A2"><f>B9*2</f><v>10</v></c><c r="B2"><f>B9*3</f></c><c r="C2" s="1"><v>45292</v></c><c r="D2" t="b"><v>1</v></c><c r="E2" t="e"><v>#DIV/0!</v></c><c r="F2" t="inlineStr"><is><t>内联</t></is></c><c r="G2" s="2"><v>45293</v></c></row>
<row r="4"><c r="C4" t="s"><v>0</v></c></row>
</sheetData></worksheet>`,
"xl/worksheets/sheet2.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1"><v>7</v></c></row></sheetData></worksheet>`,
}
}
func findTable(doc core.ParsedDocument, after int) *core.DocBlock {
for i := after; i < len(doc.Blocks); i++ {
if doc.Blocks[i].Type == core.BlockTable {
return &doc.Blocks[i]
}
}
return nil
}
func TestParseXlsxBuiltFromParts(t *testing.T) {
doc := Parse("报价单.xlsx", "", zipBytes(t, xlsxFixture()))
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
if doc.Meta.Confidence != 0.9 {
t.Fatalf("confidence: %v", doc.Meta.Confidence)
}
// 工作表顺序取 workbook.xml(报价在前),隐藏表标注;绝对 target /xl/… 也能定位。
if doc.Blocks[0].Type != core.BlockHeading || doc.Blocks[0].Text != "报价" {
t.Fatalf("first block: %+v", doc.Blocks[0])
}
heads := []string{}
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
t.Fatalf("headings: %v", heads)
}
tbl := findTable(doc, 0)
if tbl == nil || len(tbl.Rows) != 3 {
t.Fatalf("table: %+v", tbl)
}
row1, row2, row4 := tbl.Rows[0], tbl.Rows[1], tbl.Rows[2]
if row1[0] != "物料" || row1[1] != "单价" {
t.Errorf("header row (phonetic must be excluded): %v", row1)
}
want := []string{"10", "=B9*3", "2024-01-01", "TRUE", "#DIV/0!", "内联", "2024-01-02"}
if len(row2) != len(want) {
t.Fatalf("row2: %v", row2)
}
for i, w := range want {
if row2[i] != w {
t.Errorf("row2[%d]: got %q want %q", i, row2[i], w)
}
}
// 稀疏单元格按引用就位:C4 → 第三列。
if len(row4) != 3 || row4[0] != "" || row4[1] != "" || row4[2] != "物料" {
t.Errorf("sparse row: %v", row4)
}
if !strings.Contains(doc.PlainText(), "2024-01-01") {
t.Error("PlainText missing date text")
}
}
func TestParseXlsxEmptySheetSkipped(t *testing.T) {
files := xlsxFixture()
files["xl/worksheets/sheet2.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"/></sheetData></worksheet>`
doc := Parse("x.xlsx", "", zipBytes(t, files))
heads := 0
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads++
}
}
if heads != 1 || len(doc.Warnings) != 0 {
t.Fatalf("empty sheet must be skipped: blocks=%+v warnings=%v", doc.Blocks, doc.Warnings)
}
}
func TestParseXlsxInvalid(t *testing.T) {
doc := Parse("坏.xlsx", "", []byte("not a zip"))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
t.Fatalf("invalid xlsx should fail visibly: %+v %v", doc.Meta, doc.Warnings)
}
files := xlsxFixture()
delete(files, "xl/workbook.xml")
doc = Parse("无工作簿.xlsx", "", zipBytes(t, files))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
t.Fatalf("missing workbook.xml should fail visibly: %v", doc.Warnings)
}
}
func TestParseXlsxCellCap(t *testing.T) {
old := xlsxMaxCells
xlsxMaxCells = 3
defer func() { xlsxMaxCells = old }()
doc := Parse("超限.xlsx", "", zipBytes(t, xlsxFixture()))
if len(doc.Blocks) == 0 {
t.Fatal("truncated parse should keep partial blocks")
}
if len(doc.Warnings) == 0 || doc.Meta.Confidence != 0.6 {
t.Fatalf("truncation must warn and lower confidence: %v %v", doc.Meta.Confidence, doc.Warnings)
}
if !strings.Contains(strings.Join(doc.Warnings, ";"), "上限") {
t.Errorf("warnings: %v", doc.Warnings)
}
}
func TestParseXlsxPartSizeCap(t *testing.T) {
old := ooxmlPartLimit
ooxmlPartLimit = 16
defer func() { ooxmlPartLimit = old }()
doc := Parse("炸弹.xlsx", "", zipBytes(t, xlsxFixture()))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
t.Fatalf("oversized part must fail visibly: %+v %v", doc.Meta, doc.Warnings)
}
}
func TestParseXlsxDate1904(t *testing.T) {
files := xlsxFixture()
files["xl/workbook.xml"] = strings.Replace(files["xl/workbook.xml"],
"<sheets>", `<workbookPr date1904="1"/><sheets>`, 1)
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1" s="1"><v>1</v></c><c r="B1" s="1"><v>0.520833333333333</v></c></row></sheetData></worksheet>`
doc := Parse("mac.xlsx", "", zipBytes(t, files))
tbl := findTable(doc, 0)
// 1904 历法:serial 0 = 1904-01-01,serial 1 = 1904-01-02。
if tbl == nil || tbl.Rows[0][0] != "1904-01-02" {
t.Fatalf("1904 epoch: %+v", tbl)
}
if !strings.Contains(tbl.Rows[0][1], "1904-01-01 12:30") {
t.Errorf("time fraction: %q", tbl.Rows[0][1])
}
}
const pNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
const aNS = "http://schemas.openxmlformats.org/drawingml/2006/main"
func sp(phType, text string) string {
ph := ""
if phType != "" {
ph = `<p:nvPr><p:ph type="` + phType + `"/></p:nvPr>`
} else {
ph = `<p:nvPr/>`
}
return `<p:sp><p:nvSpPr><p:cNvPr id="2" name="S"/><p:cNvSpPr/></p:nvSpPr>` + ph + `<p:spPr/>` +
`<p:txBody><a:bodyPr/><a:p><a:r><a:t>` + text + `</a:t></a:r></a:p></p:txBody></p:sp>`
}
func pptxFixture() map[string]string {
presRel := func(id, target string) string {
return `<Relationship Id="` + id + `" Type="` + relTypeNS + `/slide" Target="` + target + `"/>`
}
slide1 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `" xmlns:r="` + relTypeNS + `"><p:cSld><p:spTree>
<p:nvGrpSpPr/><p:grpSpPr/>
` + sp("title", "封面标题") + `
` + sp("body", "") + `
<p:sp><p:nvSpPr><p:cNvPr id="4" name="B"/><p:cNvSpPr/></p:nvSpPr><p:nvPr/><p:spPr/><p:txBody><a:bodyPr/>
<a:p><a:r><a:t>第一行</a:t></a:r><a:br/><a:r><a:t>第二行</a:t></a:r></a:p></p:txBody></p:sp>
<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>` + sp("", "组合内文本") + `</p:grpSp>
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
<mc:Choice Requires="p14">` + sp("", "选内文本") + `</mc:Choice>
<mc:Fallback>` + sp("", "弃内文本") + `</mc:Fallback>
</mc:AlternateContent>
<p:sp><p:nvSpPr><p:cNvPr id="6" name="N"/><p:cNvSpPr/></p:nvSpPr><p:nvPr><p:ph type="sldNum"/></p:nvPr><p:spPr/><p:txBody><a:p><a:fld id="{F1}"><a:t>3</a:t></a:fld></a:p></p:txBody></p:sp>
<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm/><a:graphic><a:graphicData uri="` + aNS + `/table"><a:tbl>
<a:tr h="1"><a:tc gridSpan="2"><a:txBody><a:p><a:r><a:t>合并头</a:t></a:r></a:p></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>列三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
<a:tr h="1"><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>值三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
</a:tbl></a:graphicData></a:graphic></p:graphicFrame>
</p:spTree></p:cSld></p:sld>`
slide2 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "第二页要点") + `
</p:spTree></p:cSld></p:sld>`
return map[string]string{
"ppt/presentation.xml": `<p:presentation xmlns:p="` + pNS + `" xmlns:r="` + relTypeNS + `"><p:sldIdLst>
<p:sldId id="256" r:id="rIdA"/><p:sldId id="257" r:id="rIdB"/>
</p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
presRel("rIdA", "slides/slide1.xml") + presRel("rIdB", "slides/slide2.xml") + `</Relationships>`,
"ppt/slides/slide1.xml": slide1,
"ppt/slides/slide2.xml": slide2,
"ppt/slides/_rels/slide1.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="` + relTypeNS + `/notesSlide" Target="../notesSlides/notesSlide1.xml"/></Relationships>`,
// slide2 的备注故意缺失(无 rels 文件)。
"ppt/notesSlides/notesSlide1.xml": `<p:notes xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "补充说明") + `
</p:spTree></p:cSld></p:notes>`,
}
}
func TestParsePptxBuiltFromParts(t *testing.T) {
doc := Parse("演示.pptx", "", zipBytes(t, pptxFixture()))
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 || doc.Meta.Confidence != 0.9 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heads []string
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
// sldIdLst 顺序:slide1(有标题)在前;slide2 无标题占位符 → 无标题兜底。
if len(heads) != 2 || heads[0] != "幻灯片 1:封面标题" || heads[1] != "幻灯片 2" {
t.Fatalf("headings: %v", heads)
}
plain := doc.PlainText()
for _, want := range []string{"第一行\n第二行", "组合内文本", "选内文本", "备注:补充说明"} {
if !strings.Contains(plain, want) {
t.Errorf("missing %q in plain text", want)
}
}
if strings.Contains(plain, "弃内文本") {
t.Error("mc:Fallback content must be skipped")
}
// 页码占位符与字段文本不出现。
if strings.Contains(plain, "\n3\n") {
t.Error("slide number placeholder text leaked")
}
// 表格:gridSpan 占位 + hMerge 续格留空,保持三列对齐。
tbl := findTable(doc, 0)
if tbl == nil || len(tbl.Rows) != 2 {
t.Fatalf("table: %+v", tbl)
}
if strings.Join(tbl.Rows[0], "|") != "合并头||列三" {
t.Errorf("row1: %v", tbl.Rows[0])
}
if strings.Join(tbl.Rows[1], "|") != "||值三" {
t.Errorf("row2: %v", tbl.Rows[1])
}
}
func TestParsePptxInvalidAndEmpty(t *testing.T) {
doc := Parse("坏.pptx", "", []byte("garbage"))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
t.Fatalf("invalid pptx: %+v %v", doc.Meta, doc.Warnings)
}
files := pptxFixture()
files["ppt/presentation.xml"] = `<p:presentation xmlns:p="` + pNS + `"></p:presentation>`
doc = Parse("空演示.pptx", "", zipBytes(t, files))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
t.Fatalf("no slides: %v", doc.Warnings)
}
}
func TestParseLegacyOfficeHint(t *testing.T) {
for ext, want := range map[string]string{
"doc": "docx", "xls": "xlsx", "ppt": "pptx",
} {
doc := Parse("旧档."+ext, "", []byte{0xD0, 0xCF})
if doc.Meta.Parser != "builtin:legacy-office" {
t.Fatalf("%s parser: %+v", ext, doc.Meta)
}
if len(doc.Blocks) != 0 || len(doc.Warnings) != 1 {
t.Fatalf("%s blocks/warnings: %+v %v", ext, doc.Blocks, doc.Warnings)
}
if !strings.Contains(doc.Warnings[0], want) {
t.Errorf("%s hint should mention %s: %v", ext, want, doc.Warnings[0])
}
}
}
// 注册表与分派行为一致:每个已注册格式的解析器 ID 与 Parse 分派结果一致,
// 且 builtin 解析器覆盖的扩展名都出现在注册表里。
func TestParsersDescriptionMatchesDispatch(t *testing.T) {
byFormat := map[string]string{}
for _, d := range ParsersDescription() {
if d.Source != core.SourceBuiltin {
continue
}
for _, f := range d.Formats {
byFormat[f] = d.ID
}
}
check := map[string]string{
"md": "builtin:md", "markdown": "builtin:md",
"txt": "builtin:txt", "text": "builtin:txt", "csv": "builtin:txt", "log": "builtin:txt",
"docx": "builtin:docx", "pdf": "builtin:pdf",
"xlsx": "builtin:xlsx", "xlsm": "builtin:xlsx",
"pptx": "builtin:pptx", "pptm": "builtin:pptx", "ppsx": "builtin:pptx",
"doc": "builtin:legacy-office", "xls": "builtin:legacy-office", "ppt": "builtin:legacy-office",
}
for ext, wantID := range check {
got, ok := byFormat[ext]
if !ok {
t.Errorf("format .%s missing from registry", ext)
continue
}
doc := Parse("a."+ext, "", []byte("x"))
if doc.Meta.Parser != wantID || got != wantID {
t.Errorf(".%s: registry=%s dispatch=%s want=%s", ext, got, doc.Meta.Parser, wantID)
}
}
}
// TestParseXlsxWideColumnSkipsCellNotSheets:稀疏样式单元格落在远端列(XFD 区,
// 列序 ≥1024)只跳过该格并告警——不误报单元格上限、不丢弃后续工作表。
func TestParseXlsxWideColumnSkipsCellNotSheets(t *testing.T) {
files := xlsxFixture()
// 第一表加一个列引用 XFD1(16384 列)的样式单元格;第二表(隐藏的"草稿")必须照常解析。
files["xl/worksheets/sheet1.xml"] = strings.Replace(files["xl/worksheets/sheet1.xml"],
"</row>\n<row r=\"4\"", "</row>\n<row r=\"3\"><c r=\"XFD3\" t=\"inlineStr\"><is><t>边角样式</t></is></c></row>\n<row r=\"4\"", 1)
doc := Parse("宽表.xlsx", "", zipBytes(t, files))
heads := []string{}
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
t.Fatalf("later sheets must survive a wide column: headings=%v warnings=%v", heads, doc.Warnings)
}
joined := strings.Join(doc.Warnings, ";")
if !strings.Contains(joined, "超过 1024 列") {
t.Fatalf("wide-column warning missing: %v", doc.Warnings)
}
if strings.Contains(joined, "单元格总数") {
t.Fatalf("cell-cap warning must not fire for a wide column: %v", doc.Warnings)
}
if doc.Meta.Confidence != 0.6 {
t.Fatalf("partial-content confidence expected 0.6: %v", doc.Meta.Confidence)
}
// 被跳过的远端列内容不得混进表格。
for _, b := range doc.Blocks {
if b.Type == core.BlockTable {
for _, row := range b.Rows {
for _, cell := range row {
if strings.Contains(cell, "边角样式") {
t.Fatalf("skipped wide-column cell leaked into table: %v", row)
}
}
}
}
}
}
// TestParseXlsxBadSheetXMLWarns:单张工作表 XML 损坏必须显式告警(其余表照常),
// 与提案「显式失败,不静默」一致。
func TestParseXlsxBadSheetXMLWarns(t *testing.T) {
files := xlsxFixture()
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row`
doc := Parse("坏表.xlsx", "", zipBytes(t, files))
if len(doc.Warnings) == 0 {
t.Fatal("malformed sheet XML must warn")
}
joined := strings.Join(doc.Warnings, ";")
if !strings.Contains(joined, "工作表 报价") || !strings.Contains(joined, "XML 解码失败") {
t.Fatalf("warning should name the sheet and the failure: %v", doc.Warnings)
}
// 第二张表照常解析。
found := false
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading && b.Text == "草稿(隐藏)" {
found = true
}
}
if !found {
t.Fatalf("healthy sheets must still parse: %+v", doc.Blocks)
}
if doc.Meta.Confidence != 0.6 {
t.Fatalf("partial parse confidence expected 0.6: %v", doc.Meta.Confidence)
}
}
+291
View File
@@ -0,0 +1,291 @@
package ingest
// pptx/pptm/ppsx(PresentationML)内置解析器。幻灯片顺序取 presentation.xml
// 的 sldIdLst;标题占位符(title/ctrTitle)产标题块,正文形状按段落输出
//(a:br 换行),组合形状经同一 token 走查天然递归;mc:AlternateContent 只取
// Choice 分支;graphicFrame 内的表格按 tr/tc 输出(gridSpan/hMerge 占位保列
// 对齐);备注页正文输出「备注:…」;页码/日期/页脚占位符跳过。图片与
// SmartArt 不解析。纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"strconv"
"strings"
core "agent-system-core"
)
func parsePptx(content []byte) ([]core.DocBlock, []string) {
zr, err := openZip(content)
if err != nil {
return nil, []string{"pptx 解析失败:" + err.Error()}
}
slides, warns := pptxSlideOrder(zr)
if slides == nil && len(warns) > 0 {
return nil, warns
}
var blocks []core.DocBlock
for i, sp := range slides {
n := i + 1
data, err := zipPart(zr, sp)
if err != nil {
warns = append(warns, fmt.Sprintf("幻灯片 %d 读取失败:%v", n, err))
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: fmt.Sprintf("幻灯片 %d", n)})
continue
}
title, paras, tables := pptxSlideBlocks(data)
heading := fmt.Sprintf("幻灯片 %d", n)
if title != "" {
heading += ":" + title
}
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: heading})
blocks = append(blocks, paras...)
blocks = append(blocks, tables...)
if note := pptxSlideNotes(zr, sp); note != "" {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: "备注:" + note})
}
}
if len(blocks) == 0 && len(warns) == 0 {
warns = append(warns, "pptx 无可解析的幻灯片内容")
}
return blocks, warns
}
// pptxSlideOrder 读 presentation.xml 的 sldIdLst → 幻灯片部件名(按顺序)。
func pptxSlideOrder(zr *zip.Reader) ([]string, []string) {
data, err := zipPart(zr, "ppt/presentation.xml")
if err != nil {
return nil, []string{"pptx 解析失败:" + err.Error()}
}
rels, err := parseRels(zr, "ppt/presentation.xml")
if err != nil {
return nil, []string{"pptx 解析失败:" + err.Error()}
}
dec := xml.NewDecoder(bytes.NewReader(data))
var out, warns []string
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return out, append(warns, "presentation.xml 解码失败:"+err.Error())
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "sldId" {
continue
}
rel, ok := rels[ridAttr(st)]
if !ok || !relIsType(rel, "/slide") {
warns = append(warns, "幻灯片关系缺失或类型不符")
continue
}
out = append(out, resolveRelTarget("ppt", rel.Target))
}
return out, warns
}
// pptxSlideBlocks 解析一张幻灯片:标题、正文段落、表格块。
// 表格块统一附在段落后(图形框与正文的精确穿插对检索无价值)。
func pptxSlideBlocks(data []byte) (title string, paras []core.DocBlock, tables []core.DocBlock) {
dec := xml.NewDecoder(bytes.NewReader(data))
var stack []string
has := func(name string) bool {
for _, s := range stack {
if s == name {
return true
}
}
return false
}
skip := 0 // mc:Fallback 子树深度
// 形状状态
spDepth := -1
phSkip := false
isTitle := false
var spParas []string
var para strings.Builder
paraOpen := false
// 文本收集目标
textInPara := false
textInCell := false
// 表格状态
var tblRows [][]string
var tblRow []string
var cellBuf strings.Builder
cellOpen := false
gridSpan := 1
cellHM := false
finishPara := func() {
if !paraOpen {
return
}
paraOpen = false
if s := strings.TrimRight(para.String(), "\n"); strings.TrimSpace(s) != "" {
spParas = append(spParas, strings.TrimSpace(s))
}
para.Reset()
}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", paras, tables
}
switch t := tok.(type) {
case xml.StartElement:
if skip > 0 {
skip++
continue
}
local := t.Name.Local
switch local {
case "Fallback":
if has("AlternateContent") {
skip = 1
continue
}
case "sp":
spDepth = len(stack)
phSkip, isTitle, spParas = false, false, nil
case "ph":
if spDepth >= 0 && len(stack) > spDepth {
switch attrLocal(t, "type") {
case "title", "ctrTitle":
isTitle = true
case "dt", "ftr", "sldNum":
phSkip = true
}
}
case "p":
if spDepth >= 0 && has("txBody") && !has("tbl") {
finishPara()
paraOpen = true
para.Reset()
} else if cellOpen {
if cellBuf.Len() > 0 {
cellBuf.WriteByte('\n')
}
}
case "br":
if paraOpen {
para.WriteByte('\n')
}
case "t":
if has("fld") {
break // 字段(页码/日期)不取
}
textInPara = paraOpen
textInCell = cellOpen && !paraOpen
case "tc":
if has("tbl") {
cellBuf.Reset()
cellOpen = true
gridSpan, _ = strconv.Atoi(attrLocal(t, "gridSpan"))
if gridSpan < 1 {
gridSpan = 1
}
hm := attrLocal(t, "hMerge")
cellHM = hm == "1" || strings.EqualFold(hm, "true")
}
}
stack = append(stack, local)
case xml.CharData:
if textInPara {
para.Write(t)
} else if textInCell {
cellBuf.Write(t)
}
case xml.EndElement:
if skip > 0 {
skip--
continue
}
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
switch t.Name.Local {
case "t":
textInPara, textInCell = false, false
case "p":
finishPara()
case "sp":
if spDepth >= 0 && len(stack) == spDepth {
if !phSkip && len(spParas) > 0 {
if isTitle && title == "" {
title = strings.Join(spParas, " ")
} else {
for _, p := range spParas {
paras = append(paras, core.DocBlock{Type: core.BlockPara, Text: p})
}
}
}
spDepth = -1
phSkip, isTitle, spParas = false, false, nil
}
case "tc":
if cellOpen {
text := strings.TrimSpace(cellBuf.String())
if cellHM {
text = "" // 横向合并的续格:占位保列对齐
}
tblRow = append(tblRow, text)
for i := 1; i < gridSpan; i++ {
tblRow = append(tblRow, "") // 合并占位保列对齐
}
cellOpen = false
}
case "tr":
if len(tblRow) > 0 {
for len(tblRow) > 0 && tblRow[len(tblRow)-1] == "" {
tblRow = tblRow[:len(tblRow)-1]
}
if len(tblRow) > 0 {
tblRows = append(tblRows, tblRow)
}
tblRow = nil
}
case "tbl":
if len(tblRows) > 0 {
tables = append(tables, core.DocBlock{Type: core.BlockTable, Rows: tblRows})
}
tblRows = nil
}
}
}
finishPara()
return title, paras, tables
}
// pptxSlideNotes 读幻灯片的备注页正文;无备注返回空串。
func pptxSlideNotes(zr *zip.Reader, slidePart string) string {
rels, err := parseRels(zr, slidePart)
if err != nil {
return ""
}
for _, rel := range rels {
if !relIsType(rel, "/notesSlide") {
continue
}
data, err := zipPart(zr, resolveRelTarget(path.Dir(slidePart), rel.Target))
if err != nil {
return ""
}
_, paras, _ := pptxSlideBlocks(data)
var lines []string
for _, p := range paras {
lines = append(lines, p.Text)
}
return strings.Join(lines, "\n")
}
return ""
}
+425
View File
@@ -0,0 +1,425 @@
package ingest
// xlsx/xlsm(SpreadsheetML)内置解析器。工作表顺序取 workbook.xml 的 sheets
// 列表(target 经关系部件解析,支持相对/绝对路径);共享字符串含富文本 run
//(注音 rPh 无对应解码字段,天然排除);单元格覆盖 inlineStr/str/b/e/n,
// 公式取缓存值、无缓存回显公式;日期按数字格式(内置 ID 与自定义记号)转
// ISO,1900/1904 双日期系统;稀疏单元格按引用就位。纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
core "agent-system-core"
)
// 解析上限(包级变量,测试可覆盖)。
var (
xlsxMaxCells = 200000
xlsxMaxCols = 1024
)
type xlsxSheetRef struct {
Name string
State string
Path string
IsSheet bool
}
func parseXlsx(content []byte) ([]core.DocBlock, []string) {
zr, err := openZip(content)
if err != nil {
return nil, []string{"xlsx 解析失败:" + err.Error()}
}
sheets, date1904, warns := xlsxWorkbookSheets(zr)
if len(warns) > 0 && sheets == nil {
return nil, warns
}
shared := xlsxSharedStrings(zr)
dateStyles := xlsxDateStyles(zr)
var blocks []core.DocBlock
cells := 0
for _, sh := range sheets {
if !sh.IsSheet || sh.Path == "" {
continue // chartsheet 等非工作表部件
}
data, err := zipPart(zr, sh.Path)
if err != nil {
warns = append(warns, fmt.Sprintf("工作表 %s 读取失败:%v", sh.Name, err))
continue
}
table, used, trunc, wide, sheetWarn := xlsxSheetBlocks(data, shared, dateStyles, date1904, cells)
cells = used
if sheetWarn != "" {
warns = append(warns, fmt.Sprintf("工作表 %s:%s", sh.Name, sheetWarn))
}
if wide {
warns = append(warns, fmt.Sprintf("工作表 %s 含超过 %d 列的稀疏单元格,已跳过该列(其余内容不受影响)", sh.Name, xlsxMaxCols))
}
if table == nil {
continue // 空表(读取失败/坏 XML 已单独告警)
}
title := sh.Name
if sh.State == "hidden" || sh.State == "veryHidden" {
title += "(隐藏)"
}
blocks = append(blocks,
core.DocBlock{Type: core.BlockHeading, Level: 2, Text: title},
*table,
)
if trunc {
warns = append(warns, fmt.Sprintf("单元格总数超过 %d 上限,工作表 %s 之后未解析", xlsxMaxCells, sh.Name))
break
}
}
if len(blocks) == 0 && len(warns) == 0 {
warns = append(warns, "xlsx 无可解析的工作表内容")
}
return blocks, warns
}
// xlsxWorkbookSheets 读 workbook.xml:工作表顺序、隐藏状态、1904 日期系统。
func xlsxWorkbookSheets(zr *zip.Reader) (sheets []xlsxSheetRef, date1904 bool, warns []string) {
data, err := zipPart(zr, "xl/workbook.xml")
if err != nil {
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
}
rels, err := parseRels(zr, "xl/workbook.xml")
if err != nil {
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
}
dec := xml.NewDecoder(bytes.NewReader(data))
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return sheets, date1904, append(warns, "workbook.xml 解码失败:"+err.Error())
}
st, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch st.Name.Local {
case "workbookPr":
if v := attrLocal(st, "date1904"); v == "1" || strings.EqualFold(v, "true") {
date1904 = true
}
case "sheet":
ref := xlsxSheetRef{Name: attrLocal(st, "name"), State: attrLocal(st, "state")}
if rel, ok := rels[ridAttr(st)]; ok {
ref.Path = resolveRelTarget("xl", rel.Target)
ref.IsSheet = relIsType(rel, "/worksheet")
} else {
warns = append(warns, fmt.Sprintf("工作表 %s 的关系缺失", ref.Name))
}
sheets = append(sheets, ref)
}
}
return sheets, date1904, warns
}
// xlsxSharedStrings 读共享字符串表;富文本按 run 拼接,rPh 注音不进结果。
func xlsxSharedStrings(zr *zip.Reader) []string {
data, err := zipPart(zr, "xl/sharedStrings.xml")
if err != nil {
return nil
}
dec := xml.NewDecoder(bytes.NewReader(data))
var out []string
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return out
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "si" {
continue
}
var si struct {
T string `xml:"t"`
Runs []struct {
T string `xml:"t"`
} `xml:"r"`
}
if err := dec.DecodeElement(&si, &st); err != nil {
continue
}
if si.T != "" || len(si.Runs) == 0 {
out = append(out, si.T)
continue
}
parts := make([]string, 0, len(si.Runs))
for _, r := range si.Runs {
parts = append(parts, r.T)
}
out = append(out, strings.Join(parts, ""))
}
return out
}
// xlsxDateStyles 返回按日期呈现的样式索引集合(cellXfs 下标)。
func xlsxDateStyles(zr *zip.Reader) map[int]bool {
data, err := zipPart(zr, "xl/styles.xml")
if err != nil {
return nil
}
custom := map[int]string{}
date := map[int]bool{}
dec := xml.NewDecoder(bytes.NewReader(data))
inCellXfs := false
xfIdx := 0
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return date
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "numFmt":
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
custom[id] = attrLocal(t, "formatCode")
case "cellXfs":
inCellXfs = true
xfIdx = 0
case "xf":
if !inCellXfs {
continue
}
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
if xlsxFmtIsDate(id, custom) {
date[xfIdx] = true
}
xfIdx++
}
case xml.EndElement:
if t.Name.Local == "cellXfs" {
inCellXfs = false
}
}
}
return date
}
// xlsxFmtIsDate:自定义格式按记号判断;内置 ID 覆盖常见日期/时间区间。
func xlsxFmtIsDate(id int, custom map[int]string) bool {
if code, ok := custom[id]; ok {
return formatCodeIsDate(code)
}
switch {
case id >= 14 && id <= 22, id >= 27 && id <= 36,
id >= 45 && id <= 47, id >= 50 && id <= 58:
return true
}
return false
}
// formatCodeIsDate 剥掉引号字面量与方括号节([Red]、[DBNum1]、[$-409] 等)后,
// 剩余字母含 y/d/h/s/m 记号即按日期处理。
func formatCodeIsDate(code string) bool {
var b strings.Builder
inQuote, inBracket := false, false
runes := []rune(code)
for i := 0; i < len(runes); i++ {
r := runes[i]
switch {
case r == '"':
inQuote = !inQuote
case inQuote:
case r == '[':
inBracket = true
case r == ']':
inBracket = false
case inBracket:
case r == '\\':
i++ // 转义字符:连同下一字符跳过
default:
b.WriteRune(r)
}
}
return strings.ContainsAny(strings.ToLower(b.String()), "ydhsm")
}
type xlsxCellEl struct {
Ref string `xml:"r,attr"`
T string `xml:"t,attr"`
S string `xml:"s,attr"`
IS *struct {
T string `xml:"t"`
} `xml:"is"`
F string `xml:"f"`
V string `xml:"v"`
}
type xlsxRowEl struct {
Cells []xlsxCellEl `xml:"c"`
}
// xlsxSheetBlocks 解析单张工作表;返回表格块、累计单元格数、是否触达单元格
// 上限、是否跳过超宽列、以及坏 XML 的告警(显式失败,不静默)。无内容行
// 返回 nil 表。
func xlsxSheetBlocks(data []byte, shared []string, dateStyles map[int]bool, date1904 bool, cellsUsed int) (*core.DocBlock, int, bool, bool, string) {
dec := xml.NewDecoder(bytes.NewReader(data))
var rows [][]string
count := cellsUsed
truncated := false
wide := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, count, truncated, wide, "XML 解码失败:" + err.Error()
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "row" {
continue
}
var re xlsxRowEl
if err := dec.DecodeElement(&re, &st); err != nil {
continue
}
cells := map[int]string{}
maxCol := -1
for _, c := range re.Cells {
if count >= xlsxMaxCells {
truncated = true
break
}
count++
col := xlsxColIndex(c.Ref)
if col < 0 {
col = maxCol + 1 // 无引用:按出现顺序接续
}
if col >= xlsxMaxCols {
// 稀疏样式单元格常落在远端列(XFD 区):跳过该格并告警,
// 不中断本表其余内容,也不阻断后续工作表。
wide = true
continue
}
cells[col] = xlsxCellText(c, shared, dateStyles, date1904)
if col > maxCol {
maxCol = col
}
}
if len(cells) == 0 {
continue
}
full := make([]string, maxCol+1)
for i, v := range cells {
full[i] = v
}
for len(full) > 0 && strings.TrimSpace(full[len(full)-1]) == "" {
full = full[:len(full)-1]
}
if len(full) > 0 {
rows = append(rows, full)
}
}
if len(rows) == 0 {
return nil, count, truncated, wide, ""
}
return &core.DocBlock{Type: core.BlockTable, Rows: rows}, count, truncated, wide, ""
}
// xlsxCellText 把单元格转成展示文本。
func xlsxCellText(c xlsxCellEl, shared []string, dateStyles map[int]bool, date1904 bool) string {
text := ""
switch c.T {
case "s":
if idx, err := strconv.Atoi(c.V); err == nil && idx >= 0 && idx < len(shared) {
text = shared[idx]
}
case "inlineStr":
if c.IS != nil {
text = c.IS.T
}
case "b":
if c.V == "1" || strings.EqualFold(c.V, "true") {
text = "TRUE"
} else {
text = "FALSE"
}
case "e":
text = c.V
default: // n 或空:数字 / 公式缓存值
text = c.V
if sIdx, err := strconv.Atoi(c.S); err == nil && dateStyles[sIdx] && c.V != "" {
if f, err := strconv.ParseFloat(c.V, 64); err == nil {
text = xlsxSerialString(f, date1904)
}
}
}
if text == "" && c.F != "" {
return "=" + c.F // 无缓存值的公式:回显公式本身
}
return text
}
// xlsxSerialString Excel 日期序列值 → ISO 字符串;带小数部分时输出到秒。
func xlsxSerialString(serial float64, date1904 bool) string {
days := math.Floor(serial)
frac := serial - days
var t time.Time
switch {
case date1904:
t = time.Date(1904, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
case days >= 61:
t = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
default: // 1–60:兼容 Excel 的 1900 闰年 bug 区段
t = time.Date(1899, 12, 31, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
}
if frac > 0 {
return t.Add(time.Duration(frac * 86400 * float64(time.Second))).Round(time.Second).Format("2006-01-02 15:04:05")
}
return t.Format("2006-01-02")
}
// xlsxColIndex "BC12" → 0 起列号;无引用或非法返回 -1。
func xlsxColIndex(ref string) int {
if ref == "" {
return -1
}
i := 0
for i < len(ref) {
ch := ref[i]
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
i++
continue
}
break
}
if i == 0 || i == len(ref) {
return -1
}
for _, ch := range ref[i:] {
if ch < '0' || ch > '9' {
return -1
}
}
n := 0
for _, ch := range ref[:i] {
c := ch
if c >= 'a' && c <= 'z' {
c = c - 'a' + 'A'
}
n = n*26 + int(c-'A') + 1
}
return n - 1
}
+56 -1
View File
@@ -24,20 +24,74 @@ type MatchResult struct {
ID string
Slots map[string]string
Missing []SlotQuestion
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
Commands []Command
}
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
// Router/runmgr 等消费方只依赖本包表面。
type (
Command = agentintent.Command
ActionRef = agentintent.ActionRef
)
const (
CommandStart = agentintent.CommandStart
ActionAuto = agentintent.ActionAuto
ActionCommand = agentintent.ActionCommand
ActionWorkflow = agentintent.ActionWorkflow
ActionBlueprint = agentintent.ActionBlueprint
ActionHandler = agentintent.ActionHandler
)
type Gate struct {
mu sync.RWMutex
reg *agentintent.Registry
rec *agentintent.FunnelRecognizer
specs int
specsByID map[string]agentintent.IntentSpec
// 慢通道(I2):nil = 不启用(默认,行为与 I1 一致)。
sem agentintent.SemanticScorer
semThresh float64
gen agentintent.CommandGenerator
}
func New() *Gate {
return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)}
}
// SetSemantic 接线语义打分器(慢通道中位)。识别器随之重建;
// 传 nil 关闭语义层。阈值 ≤0 用库默认(0.8)。
func (g *Gate) SetSemantic(s agentintent.SemanticScorer, threshold float64) {
g.mu.Lock()
defer g.mu.Unlock()
g.sem, g.semThresh = s, threshold
g.rebuildRecognizerLocked()
}
// SetGenerator 接线模型命令生成器(慢通道末位)。识别器随之重建;
// 传 nil 关闭生成层。
func (g *Gate) SetGenerator(gen agentintent.CommandGenerator) {
g.mu.Lock()
defer g.mu.Unlock()
g.gen = gen
g.rebuildRecognizerLocked()
}
// rebuildRecognizerLocked 按当前慢通道配置重建识别器(调用方持写锁)。
func (g *Gate) rebuildRecognizerLocked() {
if g.sem == nil && g.gen == nil {
g.rec = agentintent.NewRecognizer(agentintent.Options{}, nil, nil)
return
}
g.rec = agentintent.NewRecognizer(agentintent.Options{
Semantic: g.sem,
SemanticThreshold: g.semThresh,
Generator: g.gen,
}, nil, nil)
}
// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
if len(overlay) == 0 {
@@ -54,7 +108,7 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
}
specs = append(specs, s)
}
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3})
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true})
if err := b.Register(specs...); err != nil {
return fmt.Errorf("intentgate: register: %w", err)
}
@@ -95,6 +149,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
switch res.Branch {
case agentintent.BranchMatched:
out.Branch = "matched"
out.Commands = res.Commands
case agentintent.BranchAmbiguous:
out.Branch = "ambiguous"
}
+85
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"testing"
agentintent "agent-intent/intent"
)
func raw(t *testing.T, v any) json.RawMessage {
@@ -91,3 +93,86 @@ func TestRecognizeKeepsSlots(t *testing.T) {
t.Fatalf("Match wrapper = %s %s", branch, id)
}
}
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
type stubScorer struct{ scores map[string]float64 }
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
if len(examples) == 0 {
return 0, nil
}
return s.scores[examples[0]], nil
}
// stubGenerator 固定返回脚本命令。
type stubGenerator struct{ cmds []Command }
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
return g.cmds, nil
}
func ambiguousOverlay(t *testing.T) []json.RawMessage {
t.Helper()
mk := func(id string, ex []string) json.RawMessage {
return raw(t, map[string]any{
"id": id, "domain": "demo", "version": 1, "priority": 5,
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
"examples": ex,
})
}
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
}
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
t.Fatal(err)
}
// 默认(未接线):规则层歧义原样透出。
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("默认关:branch = %s", branch)
}
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
branch, top := g.Match(context.Background(), "模糊词")
if branch != "matched" || top != "demo.alpha" {
t.Fatalf("语义裁决:%s/%s", branch, top)
}
// 关闭后回到歧义。
g.SetSemantic(nil, 0)
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("关闭语义层:branch = %s", branch)
}
}
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
func TestGateGeneratorProducesCommands(t *testing.T) {
g := New()
overlay := []json.RawMessage{raw(t, map[string]any{
"id": "report.generate", "domain": "report", "version": 1,
"description": "生成报告",
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
"handler": "reportHandler",
})}
if err := g.Rebuild(context.Background(), overlay); err != nil {
t.Fatal(err)
}
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
}}})
res := g.Recognize(context.Background(), "完全不相关的一句话")
if res.Branch != "matched" || res.ID != "report.generate" {
t.Fatalf("生成器命中:%+v", res)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
t.Fatalf("commands: %+v", res.Commands)
}
// 关闭后回到 fallback。
g.SetGenerator(nil)
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
t.Fatalf("关闭生成层:branch = %s", branch)
}
}
+129
View File
@@ -0,0 +1,129 @@
package intentsem
import (
"context"
"encoding/json"
"fmt"
"strings"
agentintent "agent-intent/intent"
agentllm "agent-native/runtime/llm"
)
// LightGenerator 用轻量模型实现 agentintent.CommandGenerator
// (Rasa 式 LLM command generator:只把相关流程交给生成器)。
// 提示词给出能力目录(spec id/描述/槽位键),要求模型输出严格的
// 命令 JSON 数组;解析失败或模型不可达都返回错误——库侧约定生成器
// 出错时识别保持 fallback(fail-closed)。
type LightGenerator struct {
// Stream 提供方边界(注入点;测试可脚本化)。
Stream agentllm.StreamFunc
// Model 是轻量档模型名。
Model string
// APIBase/APIKey 走 StreamOptions(客户端可按次覆盖)。
APIBase string
APIKey string
// MaxTokens 上限;0 = 1024。
MaxTokens int
}
// 生成输出契约:action 与 agentintent.ActionRef 同形(kind/id)。
type generatedCommand struct {
Kind string `json:"kind"`
Action generatedAction `json:"action,omitempty"`
Slots map[string]string `json:"slots,omitempty"`
}
type generatedAction struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// Generate 基于输入与能力目录产出命令列表。目录为空时不调用模型。
func (g LightGenerator) Generate(ctx context.Context, input string, catalog []agentintent.CatalogSpec) ([]agentintent.Command, error) {
if g.Stream == nil || len(catalog) == 0 || strings.TrimSpace(input) == "" {
return nil, nil
}
maxTok := g.MaxTokens
if maxTok <= 0 {
maxTok = 1024
}
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: generatorSystemPrompt(catalog)},
{Role: agentllm.RoleUser, Content: input},
}
ch := g.Stream(ctx, g.Model, msgs, nil, agentllm.StreamOptions{
MaxTokens: maxTok, APIBase: g.APIBase, APIKey: g.APIKey,
})
var sb strings.Builder
for ev := range ch {
switch ev.Kind {
case agentllm.KindTextDelta:
sb.WriteString(ev.Text)
case agentllm.KindError:
if ev.Err != nil {
return nil, fmt.Errorf("intentsem: stream: %w", ev.Err)
}
return nil, fmt.Errorf("intentsem: stream: %s", ev.ErrorMessage)
}
}
return parseCommands(sb.String())
}
// parseCommands 从模型输出解析命令数组:剥代码围栏,取首个 [ 到末个 ],
// 严格 JSON。任何形状不符都报错(由库侧 fail-closed 到 fallback)。
func parseCommands(raw string) ([]agentintent.Command, error) {
text := strings.TrimSpace(raw)
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
lo, hi := strings.Index(text, "["), strings.LastIndex(text, "]")
if lo < 0 || hi <= lo {
return nil, fmt.Errorf("intentsem: 输出不含命令数组")
}
var gen []generatedCommand
if err := json.Unmarshal([]byte(text[lo:hi+1]), &gen); err != nil {
return nil, fmt.Errorf("intentsem: 命令 JSON: %w", err)
}
out := make([]agentintent.Command, 0, len(gen))
for _, c := range gen {
out = append(out, agentintent.Command{
Kind: agentintent.CommandKind(c.Kind),
Action: agentintent.ActionRef{Kind: c.Action.Kind, ID: c.Action.ID},
Slots: c.Slots,
})
}
return out, nil
}
// generatorSystemPrompt 渲染能力目录。只暴露 id/描述/槽位键——
// 不暴露模式与示例(避免提示词注入借道示例文本进入慢通道)。
func generatorSystemPrompt(catalog []agentintent.CatalogSpec) string {
var sb strings.Builder
sb.WriteString("你是意图命令生成器。根据用户输入,从下面的能力目录选择并输出命令。\n")
sb.WriteString("只输出一个 JSON 数组,不要输出任何其他文字。数组元素形如:\n")
sb.WriteString(`[{"kind":"start","action":{"kind":"workflow","id":"<目录中的id>"},"slots":{"<槽位键>":"<值>"}}]` + "\n")
sb.WriteString("规则:kind 取 start/set_slot/cancel/clarify/chitchat/handoff 之一;")
sb.WriteString("start 必须带 action,action.kind 取 auto/command/workflow/blueprint/handler;")
sb.WriteString("action.id 与 slots 的键必须来自目录;用户闲聊用 chitchat(不带 action);")
sb.WriteString("无法判断时输出空数组 []。\n能力目录:\n")
for _, spec := range catalog {
sb.WriteString("- id=" + spec.ID)
if spec.Description != "" {
sb.WriteString(" 描述=" + spec.Description)
}
if spec.Handler != "" {
sb.WriteString(" handler=" + spec.Handler)
}
if len(spec.Slots) > 0 {
keys := make([]string, 0, len(spec.Slots))
for _, sl := range spec.Slots {
keys = append(keys, sl.Key)
}
sb.WriteString(" 槽位=[" + strings.Join(keys, ",") + "]")
}
sb.WriteString("\n")
}
return sb.String()
}
+149
View File
@@ -0,0 +1,149 @@
package intentsem
import (
"context"
"errors"
"strings"
"testing"
agentintent "agent-intent/intent"
agentllm "agent-native/runtime/llm"
)
// fakeEmbedder 返回脚本化向量:texts[i] → vecs[i](循环取模)。
type fakeEmbedder struct {
vecs [][]float32
err error
}
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
if f.err != nil {
return nil, f.err
}
out := make([][]float32, len(texts))
for i := range texts {
out[i] = f.vecs[i%len(f.vecs)]
}
return out, nil
}
func TestEmbeddingScorerMaxCosine(t *testing.T) {
// input 与 example2 同向(相似度 1),与 example1 正交(0)。
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{
{1, 0}, {0, 1}, {2, 0},
}}}
got, err := s.Score(context.Background(), "输入", []string{"例一", "例二"})
if err != nil {
t.Fatal(err)
}
if got != 1 {
t.Fatalf("期望最大余弦 1,得到 %v", got)
}
}
func TestEmbeddingScorerErrorPropagates(t *testing.T) {
s := EmbeddingScorer{Provider: fakeEmbedder{err: errors.New("boom")}}
if _, err := s.Score(context.Background(), "x", []string{"y"}); err == nil {
t.Fatal("期望错误上抛(库侧保持 Ambiguous)")
}
}
func TestEmbeddingScorerEmptyExamples(t *testing.T) {
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{{1}}}}
got, err := s.Score(context.Background(), "x", nil)
if err != nil || got != 0 {
t.Fatalf("空示例应得 0 分无错误,得到 %v / %v", got, err)
}
}
// scriptedStream 回放固定文本增量后关闭通道。
func scriptedStream(text string, err error) agentllm.StreamFunc {
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 3)
if err != nil {
ch <- agentllm.Event{Kind: agentllm.KindError, Err: err}
} else {
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
}
close(ch)
return ch
}
}
func catalogFixture() []agentintent.CatalogSpec {
return []agentintent.CatalogSpec{
{ID: "deploy.run", Description: "部署应用", Slots: []agentintent.CatalogSlot{{Key: "env", Required: true}}},
}
}
func TestLightGeneratorParsesCommands(t *testing.T) {
g := LightGenerator{
Stream: scriptedStream(" ```json\n[{\"kind\":\"start\",\"action\":{\"kind\":\"workflow\",\"id\":\"deploy.run\"},\"slots\":{\"env\":\"prod\"}}]\n``` ", nil),
Model: "lite",
}
cmds, err := g.Generate(context.Background(), "把应用部署到生产", catalogFixture())
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandStart {
t.Fatalf("期望 1 条 start 命令,得到 %+v", cmds)
}
if cmds[0].Action.Kind != "workflow" || cmds[0].Action.ID != "deploy.run" {
t.Fatalf("action 不符:%+v", cmds[0].Action)
}
if cmds[0].Slots["env"] != "prod" {
t.Fatalf("槽位不符:%+v", cmds[0].Slots)
}
}
func TestLightGeneratorEmptyCatalogNoCall(t *testing.T) {
called := false
g := LightGenerator{Stream: func(_ context.Context, _ string, _ []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
called = true
ch := make(chan agentllm.Event)
close(ch)
return ch
}}
cmds, err := g.Generate(context.Background(), "你好", nil)
if err != nil || cmds != nil || called {
t.Fatalf("空目录不应调用模型:cmds=%v err=%v called=%v", cmds, err, called)
}
}
func TestLightGeneratorStreamError(t *testing.T) {
g := LightGenerator{Stream: scriptedStream("", errors.New("dial refused"))}
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil ||
!strings.Contains(err.Error(), "dial refused") {
t.Fatalf("期望流错误上抛,得到 %v", err)
}
}
func TestLightGeneratorBadJSON(t *testing.T) {
g := LightGenerator{Stream: scriptedStream("我无法判断", nil)}
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil {
t.Fatal("期望解析失败返回错误(库侧 fail-closed 回 fallback)")
}
}
func TestParseCommandsFencedAndChatty(t *testing.T) {
raw := "好的,命令如下:\n```json\n[{\"kind\":\"chitchat\"}]\n```\n以上。"
cmds, err := parseCommands(raw)
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandChitchat {
t.Fatalf("期望 chitchat,得到 %+v", cmds)
}
}
func TestGeneratorPromptContainsCatalogOnly(t *testing.T) {
p := generatorSystemPrompt(catalogFixture())
for _, want := range []string{"deploy.run", "部署应用", "env"} {
if !strings.Contains(p, want) {
t.Fatalf("提示词缺少 %q", want)
}
}
if strings.Contains(p, "正例") || strings.Contains(p, "examples") {
t.Fatal("提示词不应包含示例文本(注入面)")
}
}
+62
View File
@@ -0,0 +1,62 @@
// Package intentsem 慢通道实现(I2 增量三):把 agent-intent 的
// SemanticScorer / CommandGenerator 端口接到宿主已有的向量与轻量模型
// 能力上。两个实现都是可插拔的——装配层默认不接线(设置开关关),
// 此时识别行为与 I1 完全一致。
package intentsem
import (
"context"
"math"
core "agent-system-core"
)
// EmbeddingScorer 用语义向量实现 agentintent.SemanticScorer:
// 对 input 与每个 example 取向量,返回最大余弦相似度。
// 任何错误都原样上抛——库侧约定 scorer 出错时识别保持 Ambiguous,
// 不会使 Recognize 失败(fail-open 到规则层结论)。
type EmbeddingScorer struct {
Provider core.EmbeddingProvider
}
// Score 返回 input 与 examples 的最高余弦相似度,取值 [0,1]。
func (s EmbeddingScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
if s.Provider == nil || len(examples) == 0 {
return 0, nil
}
texts := make([]string, 0, len(examples)+1)
texts = append(texts, input)
texts = append(texts, examples...)
vecs, err := s.Provider.Embed(ctx, texts)
if err != nil {
return 0, err
}
if len(vecs) != len(texts) || len(vecs[0]) == 0 {
return 0, nil
}
best := 0.0
for _, v := range vecs[1:] {
if sim := cosine(vecs[0], v); sim > best {
best = sim
}
}
return best, nil
}
// cosine 计算两个等长向量的余弦相似度;任一零向量或不等长返回 0。
func cosine(a, b []float32) float64 {
if len(a) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
fa, fb := float64(a[i]), float64(b[i])
dot += fa * fb
na += fa * fa
nb += fb * fb
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
+41
View File
@@ -0,0 +1,41 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
core "agent-system-core"
)
// agentBannedKeys 是插件代理声明禁带的键:钩子、MCP 与权限模式归宿主与
// 基座,插件画像只能声明身份与工具面(N1 WP1 验收条款)。
var agentBannedKeys = map[string]bool{
"hooks": true,
"mcp": true,
"permissionMode": true,
}
// parseAgentAsset 解析 agents/ 资产:禁带键定位到字段、未知键拒绝、
// id 必填。任一违规由调用方整批拒绝该插件(fail-closed)。
func parseAgentAsset(raw []byte) (core.PluginAgentDecl, error) {
var probe map[string]json.RawMessage
if err := json.Unmarshal(raw, &probe); err != nil {
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
}
for key := range probe {
if agentBannedKeys[key] {
return core.PluginAgentDecl{}, fmt.Errorf("插件代理不得声明 %s", key)
}
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var item core.PluginAgentDecl
if err := dec.Decode(&item); err != nil {
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
}
if item.ID == "" {
return core.PluginAgentDecl{}, fmt.Errorf("agent missing id")
}
return item, nil
}
+72
View File
@@ -0,0 +1,72 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
// writeAgentPlugin 写一个带 agents/ 资产的插件目录。
func writeAgentPlugin(t *testing.T, dir, id, agentJSON string) {
t.Helper()
root := filepath.Join(dir, id)
os.MkdirAll(filepath.Join(root, "agents"), 0o755)
write := func(rel, content string) {
if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{"id":"`+id+`","name":"代理插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
"provides":{"agents":["reviewer"]}}`)
write("agents/reviewer.json", agentJSON)
}
func TestAgentAssetLoads(t *testing.T) {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "agentplug", `{"id":"rdm-reviewer","title":"章节评审","instructions":"逐条核对来源","tools":["read_file","grep"]}`)
if err := h.LoadDir()["agentplug"]; err != nil {
t.Fatal(err)
}
for _, ld := range h.List() {
if ld.Manifest.ID != "agentplug" {
continue
}
if len(ld.Agents) != 1 || ld.Agents[0].ID != "rdm-reviewer" || len(ld.Agents[0].Tools) != 2 {
t.Fatalf("agents = %+v", ld.Agents)
}
return
}
t.Fatal("plugin not loaded")
}
// TestAgentAssetBansHostOnlyKeys:插件代理声明 hooks/mcp/permissionMode 时
// 整批拒绝且错误定位到字段(N1 WP1 验收条款)。
func TestAgentAssetBansHostOnlyKeys(t *testing.T) {
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "badplug", `{"id":"x","`+key+`":{}}`)
err := h.LoadDir()["badplug"]
if err == nil {
t.Fatalf("%s accepted", key)
}
if !strings.Contains(err.Error(), "不得声明 "+key) {
t.Fatalf("%s error 未定位到字段: %v", key, err)
}
}
}
func TestAgentAssetRejectsUnknownKeysAndMissingID(t *testing.T) {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "unknownkey", `{"id":"x","bogus":1}`)
if err := h.LoadDir()["unknownkey"]; err == nil {
t.Fatal("unknown key accepted")
}
h2, _, dir2 := newHost(t)
writeAgentPlugin(t, dir2, "noid", `{"title":"无 id"}`)
if err := h2.LoadDir()["noid"]; err == nil {
t.Fatal("missing id accepted")
}
}
+11 -2
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"agent-system-backend/internal/skills"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
@@ -59,6 +60,10 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
if err != nil {
return fmt.Errorf("plugins: %s: skill %s: %w", m.ID, name, err)
}
// 技能按开放规范校验(N2 WP4):不合规整批拒载,字段定位到键。
if err := skills.ValidateForPlugin(m.ID, name, body); err != nil {
return err
}
if ld.Skills == nil {
ld.Skills = map[string]string{}
}
@@ -98,8 +103,12 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
ld.Parsers = append(ld.Parsers, item)
}
for _, name := range m.Provides.Agents {
var item core.PluginAgentDecl
if err := readAsset(dir, "agents", name, &item); err != nil {
raw, err := readPluginFile(dir, filepath.Join("agents", name+".json"))
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
item, err := parseAgentAsset(raw)
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
ld.Agents = append(ld.Agents, item)
+32 -1
View File
@@ -2,6 +2,7 @@ package plugins
import (
"os"
"strings"
"path/filepath"
"testing"
"time"
@@ -33,7 +34,7 @@ func TestV2AssetsAndNetwork(t *testing.T) {
"provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]}
}`)
write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`)
write("skills/note.md", "# note\n")
write("skills/note.md", "---\nname: note\ndescription: 笔记技能\n---\n\n# note\n")
write("mcp/local.json", `{"name":"local","command":"echo"}`)
write("knowledge/regs.json", `{"id":"regs","title":"法规"}`)
write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`)
@@ -95,3 +96,33 @@ func TestSidecarTouch(t *testing.T) {
}
t.Fatal("sidecar did not run")
}
// N2 WP4:不合规插件技能整批拒载,错误带插件上下文与字段定位。
func TestV2InvalidSkillRejected(t *testing.T) {
host, _, dir := newHost(t)
root := filepath.Join(dir, "badskill")
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
t.Fatal(err)
}
write := func(rel, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{
"id":"badskill","name":"Bad","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
"backend":{"schemaVersion":1},
"provides":{"skills":["note"]}
}`)
// 缺 name(开放规范必填)。
write("skills/note.md", "---\ndescription: 没有名字\n---\n正文\n")
err := host.LoadDir()["badskill"]
if err == nil {
t.Fatal("不合规技能必须拒载")
}
if !strings.Contains(err.Error(), "plugins: badskill: skill note:") ||
!strings.Contains(err.Error(), "name") {
t.Fatalf("错误须带插件上下文与字段定位:%v", err)
}
}
+32
View File
@@ -124,6 +124,38 @@ func firstRef(docs []core.KnowledgeDoc) string {
return ""
}
// RetrieveShared EXT-8 余项:本人两路如常 + 追加共享两路(向量 Shared + 全文
// Shared,allow=适配器判定),RRF 融合后裁剪 topK。embedder 不可用时共享仅全文
// (与切片 2 行为一致)。
func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
own, err := p.Retrieve(ctx, owner, q)
if err != nil {
return nil, err
}
shared, err := p.st.SearchKnowledgeShared(string(owner), q, allow)
if err != nil {
return nil, err
}
// 向量共享路:embedder 可用时并入。
if p.embedder != nil && p.embeddingRoute(ctx).OK() {
if qvecs, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(qvecs) > 0 && len(qvecs[0]) > 0 {
topK := q.TopK
if topK <= 0 {
topK = 5
}
vs, e2 := p.st.SearchKnowledgeVectorsShared(ctx, string(owner), qvecs[0], topK, q, allow)
if e2 == nil {
shared = rrfMerge(vs, shared)
}
}
}
merged := rrfMerge(own, shared)
if limit := q.TopK; limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
// Retrieve 统一检索(core.Retriever)。
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
topK := q.TopK
+53
View File
@@ -0,0 +1,53 @@
package rag
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// EXT-8 余项红/绿:向量通道共享——他人 owner 的向量命中经 allow 放行可检。
// 用 hash 档 embedder(确定性向量),owner=alice 建项目知识并嵌入;bob 经
// RetrieveShared 放行后命中(含 vector Kind),拒绝时零命中。
func TestRetrieveSharedVectorChannel(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
p := NewPipeline(st, set, nil, nil)
p.SetEmbedder(SwitchingEmbedder{Provider: func() string { return "hash" }})
docs := []core.KnowledgeDoc{{Owner: "alice", ProjectID: "prj-s", Source: "plugin:rdm",
Ref: "kb-v", Chunk: "向量共享独占词"}}
// 只走管线写入(分块行+向量一体;不再手工 AddKnowledge 避免主键冲突)。
if err := indexForTest(t, st, p, docs); err != nil {
t.Fatal(err)
}
allow := func(docOwner, projectID core.ID) bool {
return docOwner == "alice" && projectID == "prj-s"
}
hits, err := p.RetrieveShared(context.Background(), "bob", core.RetrievalQuery{Text: "向量共享独占词", TopK: 5}, allow)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("authorized shared vector hit must be retrievable(向量通道共享)")
}
deny := func(docOwner, projectID core.ID) bool { return false }
if hits2, _ := p.RetrieveShared(context.Background(), "bob", core.RetrievalQuery{Text: "向量共享独占词", TopK: 5}, deny); len(hits2) != 0 {
t.Fatalf("denied shared search = %d hits, want 0", len(hits2))
}
}
// indexForTest:把 docs 直接嵌入并落向量(复用管线的公开写入路径)。
func indexForTest(t *testing.T, st *store.Store, p *Pipeline, docs []core.KnowledgeDoc) error {
t.Helper()
return p.IndexChunks(context.Background(), "alice", docs)
}
+114
View File
@@ -0,0 +1,114 @@
// Package route 是 Backend 的唯一意图 Router(I1 WP3)。
// 它把识别器产出的命令列表统一裁决为路由决策,收编原散落在
// server(chat 内联分支)与 runmgr(HITL 自由输入转轨)的两套逻辑。
//
// 优先级明文(提案 §6 要求 I1 写明):
// - auto/handler:命令总线 > 工作流 > 普通回合(与现网行为一致)。
// - 显式 command/workflow/blueprint:按声明执行;声明目标在当前环境
// 不可解析(命令不存在、工作流未注册、小队未启用)时不静默错配——
// 回落普通回合并把原因写进 Reason。
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
// 意图产出 Start/Clarify 类命令时路由优先。
//
// 当前版本的已知边界(如实记录,不静默):
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
// 本版本调用方按普通回合处理并保留 Decision 供审计。
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
// fallback,Start 只来自 matched)。
package route
import (
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
// Kind 是路由决策类型。
type Kind string
const (
KindCommand Kind = "command"
KindWorkflow Kind = "workflow"
KindSquad Kind = "squad"
KindTurn Kind = "turn"
)
// Decision 是一次路由裁决。
type Decision struct {
Kind Kind
CommandID string
Workflow core.WorkflowDef
Blueprint string
Slots map[string]string
Missing []intentgate.SlotQuestion
Reason string // 回落 turn 的原因(显式声明不可解析时)
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
}
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
type Env struct {
HasCommand func(id string) bool
WorkflowFor func(intentID string) (core.WorkflowDef, bool)
SquadReady bool
}
// Decide 裁决一次识别结果。规则:
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
func Decide(res intentgate.MatchResult, env Env) Decision {
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
if res.Branch != "matched" {
return d
}
var start *intentgate.Command
for i := range res.Commands {
if res.Commands[i].Kind == intentgate.CommandStart {
start = &res.Commands[i]
break
}
}
action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID}
if start != nil {
d.StartFound = true
action = start.Action
if len(start.Slots) > 0 {
d.Slots = start.Slots
}
}
switch action.Kind {
case intentgate.ActionCommand:
if env.HasCommand != nil && env.HasCommand(action.ID) {
d.Kind, d.CommandID = KindCommand, action.ID
return d
}
d.Reason = "声明的命令不存在:" + action.ID
case intentgate.ActionWorkflow:
if env.WorkflowFor != nil {
if def, ok := env.WorkflowFor(action.ID); ok {
d.Kind, d.Workflow = KindWorkflow, def
return d
}
}
d.Reason = "声明的工作流未注册:" + action.ID
case intentgate.ActionBlueprint:
if env.SquadReady {
d.Kind, d.Blueprint = KindSquad, action.ID
return d
}
d.Reason = "声明的小队未启用:" + action.ID
default: // auto / handler
if env.HasCommand != nil && env.HasCommand(action.ID) {
d.Kind, d.CommandID = KindCommand, action.ID
return d
}
if env.WorkflowFor != nil {
if def, ok := env.WorkflowFor(action.ID); ok {
d.Kind, d.Workflow = KindWorkflow, def
return d
}
}
}
return d
}
+83
View File
@@ -0,0 +1,83 @@
package route
import (
"testing"
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
func startCmd(action intentgate.ActionRef) intentgate.Command {
return intentgate.Command{Kind: intentgate.CommandStart, Action: action}
}
func TestDecideAutoPrefersCommandThenWorkflow(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"})}}
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
if d.Kind != KindCommand || d.CommandID != "demo.x" {
t.Fatalf("auto command = %+v", d)
}
d = Decide(in, Env{
HasCommand: func(string) bool { return false },
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
})
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
t.Fatalf("auto workflow = %+v", d)
}
d = Decide(in, Env{})
if d.Kind != KindTurn || !d.StartFound {
t.Fatalf("auto turn = %+v", d)
}
}
func TestDecideExplicitKinds(t *testing.T) {
mk := func(kind, id string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: kind, ID: id})}}
}
if d := Decide(mk(intentgate.ActionCommand, "cmd-1"), Env{HasCommand: func(id string) bool { return id == "cmd-1" }}); d.Kind != KindCommand || d.CommandID != "cmd-1" {
t.Fatalf("explicit command = %+v", d)
}
d := Decide(mk(intentgate.ActionCommand, "nope"), Env{
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
})
if d.Kind != KindTurn || d.Reason == "" {
t.Fatalf("unresolvable command = %+v", d)
}
if d := Decide(mk(intentgate.ActionWorkflow, "wf-1"), Env{
WorkflowFor: func(id string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: id}, true },
}); d.Kind != KindWorkflow || d.Workflow.ID != "wf-1" {
t.Fatalf("explicit workflow = %+v", d)
}
if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{SquadReady: true}); d.Kind != KindSquad || d.Blueprint != "bp-1" {
t.Fatalf("blueprint = %+v", d)
}
if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{}); d.Kind != KindTurn || d.Reason == "" {
t.Fatalf("blueprint without squad = %+v", d)
}
}
func TestDecideNonMatchedAndLegacyFallback(t *testing.T) {
if d := Decide(intentgate.MatchResult{Branch: "fallback"}, Env{}); d.Kind != KindTurn {
t.Fatalf("fallback = %+v", d)
}
// matched 但命令列表为空(旧消费方):按 TopID 走 auto,与现网一致。
d := Decide(intentgate.MatchResult{Branch: "matched", ID: "demo.x"}, Env{HasCommand: func(string) bool { return true }})
if d.Kind != KindCommand || d.StartFound {
t.Fatalf("legacy auto = %+v", d)
}
}
func TestSlotsFromStartCommand(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Commands: []intentgate.Command{{
Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"},
Slots: map[string]string{"topic": "电机"},
}}}
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
if d.Slots["topic"] != "电机" {
t.Fatalf("slots = %v", d.Slots)
}
}
+82
View File
@@ -0,0 +1,82 @@
package runmgr
import (
"context"
"time"
"agent-native/runtime/memory"
core "agent-system-core"
"agent-system-backend/internal/store"
)
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
// owner/项目态由适配器持有(端口本身不传租户态)。
type storeMemory struct {
st *store.Store
owner string
projectID string
}
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
if owner == "" {
owner = store.OwnerDefault
}
return &storeMemory{st: st, owner: owner, projectID: projectID}
}
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
projectID := ""
if q.Scope == "project" {
projectID = m.projectID
}
list, err := m.st.ListMemory(m.owner, q.Scope, projectID)
if err != nil {
return nil, err
}
limit := q.Limit
if limit <= 0 {
limit = memory.MaxRecallEntries
}
out := make([]memory.Entry, 0, len(list))
for _, e := range list {
out = append(out, memory.Entry{
ID: e.ID, Scope: e.Scope, ProjectID: string(e.ProjectID), Type: e.Type,
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
CreatedAt: e.CreatedAt, ExpiresAt: e.ExpiresAt,
})
if len(out) >= limit {
break
}
}
return out, nil
}
// StoreMemory 是 runCtx 的端口出口(llm_direct 经接口断言取用)。
func (c *runCtx) StoreMemory() memory.Store {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
}
// Append 写回一条(project scope 用适配器的项目上下文)。
func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
projectID := e.ProjectID
if e.Scope == "project" && projectID == "" {
projectID = m.projectID
}
now := time.Now().UTC()
created := e.CreatedAt
if created.IsZero() {
created = now
}
_, err := m.st.PutMemory(m.owner, core.MemoryEntry{
ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type,
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
CreatedAt: created, ExpiresAt: e.ExpiresAt,
})
return err
}
+94
View File
@@ -0,0 +1,94 @@
package runmgr
import (
"context"
"path/filepath"
"testing"
"time"
"agent-native/runtime/memory"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// ---- N3 WP5:storeMemory 适配器 ----
func openMemStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestStoreMemoryRecallScopeAndLimit(t *testing.T) {
st := openMemStore(t)
put := func(scope, project, typ, content string) {
t.Helper()
if _, err := st.PutMemory("u1", core.MemoryEntry{Scope: scope, ProjectID: core.ID(project), Type: typ, Content: content, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
}
put("user", "", "fact", "用户事实")
put("project", "p1", "decision", "项目决定")
put("project", "p2", "decision", "别的项目")
m := NewStoreMemory(st, "u1", "p1")
// 空 scope = 全量(迁移前 Memories() 语义)。
all, err := m.Recall(context.Background(), memory.RecallQuery{})
if err != nil || len(all) != 3 {
t.Fatalf("全量召回:%d %v", len(all), err)
}
// user scope。
users, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "user"})
if len(users) != 1 || users[0].Content != "用户事实" {
t.Fatalf("user scope:%+v", users)
}
// project scope 用适配器的项目上下文(p1,不含 p2)。
projs, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "project"})
if len(projs) != 1 || projs[0].Content != "项目决定" {
t.Fatalf("project scope:%+v", projs)
}
// Limit。
limited, _ := m.Recall(context.Background(), memory.RecallQuery{Limit: 2})
if len(limited) != 2 {
t.Fatalf("Limit:%d", len(limited))
}
}
func TestStoreMemoryAppendRoundtrip(t *testing.T) {
st := openMemStore(t)
m := NewStoreMemory(st, "u1", "p1")
future := time.Now().Add(48 * time.Hour)
if err := m.Append(context.Background(), memory.Entry{
Scope: "project", Type: "procedure", Content: "先跑 lint", Source: "agent", ExpiresAt: &future,
}); err != nil {
t.Fatal(err)
}
list, err := st.ListMemory("u1", "project", "p1")
if err != nil || len(list) != 1 {
t.Fatalf("回读:%d %v", len(list), err)
}
got := list[0]
if got.Type != "procedure" || got.Content != "先跑 lint" || got.Source != "agent" ||
got.ProjectID != "p1" || got.ExpiresAt == nil {
t.Fatalf("写入形态:%+v", got)
}
}
// runCtx 出口:同一 store 上的 runCtx 暴露同一端口语义。
func TestRunCtxStoreMemory(t *testing.T) {
c, st := newSpawnEnv(t, &gateRunner{})
if _, err := st.PutMemory(c.run.Owner, core.MemoryEntry{Scope: "user", Type: "fact", Content: "跨会话事实", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
port := c.StoreMemory()
if port == nil {
t.Fatal("StoreMemory 必须非空")
}
entries, err := port.Recall(context.Background(), memory.RecallQuery{})
if err != nil || len(entries) != 1 || entries[0].Content != "跨会话事实" {
t.Fatalf("runCtx 召回:%+v %v", entries, err)
}
}
+53
View File
@@ -1,8 +1,12 @@
package runmgr
import (
"context"
"testing"
agentllm "agent-native/runtime/llm"
agentpolicy "agent-native/runtime/policy"
core "agent-system-core"
)
@@ -36,3 +40,52 @@ func TestDefaultModeStillRunsWrite(t *testing.T) {
t.Fatalf("default write: %+v", res)
}
}
// TestConvAllowStoreKeysByName:SessionAllows 端口的键控口径保持「会话 × 工具名」,
// 不区分参数——与旧 sessionAllows 布尔参数的行为一致。
func TestConvAllowStoreKeysByName(t *testing.T) {
c, _ := newExecEnv(t)
conv := c.run.ConversationID
store := convAllowStore{mgr: c.mgr, conv: conv}
pol := agentpolicy.Policy{
Rules: []agentpolicy.Rule{{Effect: agentpolicy.EffectAsk, Tool: "run_shell"}},
SessionAllows: store,
}
if got := pol.Decide("run_shell", `{"command":"ls"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAsk {
t.Fatalf("before remember: %q, want ask", got)
}
store.Remember("run_shell", `{"command":"ls"}`)
if got := pol.Decide("run_shell", `{"command":"rm -rf /tmp/x"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAllow {
t.Fatalf("after remember, other args: %q, want allow(按名放行)", got)
}
if !c.mgr.sessionAllows(conv, "run_shell") {
t.Fatal("remember did not reach the manager table")
}
}
// TestPermissionAskRoutesToHITL:内核 ask 决策经 HITL 确认卡裁决;
// session 选项落会话放行,取消给出可读理由。
func TestPermissionAskRoutesToHITL(t *testing.T) {
c, _ := newExecEnv(t)
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
call := agentllm.ToolCall{ID: "c1", Name: "run_shell", Arguments: `{"command":"ls"}`}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "yes"}}
if v := c.PermissionAsk(context.Background(), call); !v.Allow {
t.Fatalf("yes verdict = %+v", v)
}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "session"}}
if v := c.PermissionAsk(context.Background(), call); !v.Allow {
t.Fatalf("session verdict = %+v", v)
}
if !c.mgr.sessionAllows(c.run.ConversationID, "run_shell") {
t.Fatal("session choice did not remember the allow")
}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "no"}}
if v := c.PermissionAsk(context.Background(), call); v.Allow || v.Reason == "" {
t.Fatalf("no verdict = %+v", v)
}
}
+7 -2
View File
@@ -2,6 +2,7 @@ package runmgr
import (
"fmt"
"log"
"path/filepath"
"strings"
@@ -75,14 +76,18 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
if err != nil {
return checkpoint.Result{}, err
}
log, err := m.OpenCheckpoints(owner, convID)
cpLog, err := m.OpenCheckpoints(owner, convID)
if err != nil {
return checkpoint.Result{}, err
}
res, err := log.Rewind(messageID, ordered)
res, err := cpLog.Rewind(messageID, ordered)
if err != nil {
return checkpoint.Result{}, err
}
// 保留策略(N3 WP3):成功的回滚后按 anchor 数裁到 200,日志有界。
if cerr := cpLog.Compact(200); cerr != nil {
log.Printf("[runmgr] checkpoint compact %s: %v", convID, cerr)
}
note := rewindNote(res)
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
+159 -23
View File
@@ -15,14 +15,18 @@ import (
"time"
"unicode/utf8"
nruntime "agent-native/runtime"
"agent-native/runtime/filestate"
agentllm "agent-native/runtime/llm"
agentpolicy "agent-native/runtime/policy"
"agent-native/runtime/subagent"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/route"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
@@ -42,7 +46,7 @@ type Manager struct {
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
mockSub runtime.Runner // 子代理脚本回放
squad runtime.Runner // runtime.entry=squad 时的对话入口
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.WorkflowRunner
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.CompiledWorkflowRunner
workflows *workflow.Registry // run_workflow 按 id 查找已注册定义
gate IntentGate
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
@@ -78,6 +82,7 @@ const maxBackgroundChildren = 3
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
@@ -96,6 +101,10 @@ type runHandle struct {
started time.Time
promptTokens int
completionTokens int
lastPromptTokens int
lastCompletionTokens int
cacheReadTokens int
cacheWriteTokens int
}
type hitlAnswer struct {
@@ -231,10 +240,15 @@ func (c *runCtx) ConversationSandbox() string {
}
// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。
// 计费拆分(N3 WP6):Last* 覆盖(最近一次请求)、Cache* 累计。
func (c *runCtx) AddUsage(u core.Usage) {
c.mgr.mu.Lock()
c.h.promptTokens += u.PromptTokens
c.h.completionTokens += u.CompletionTokens
c.h.lastPromptTokens = u.LastPromptTokens
c.h.lastCompletionTokens = u.LastCompletionTokens
c.h.cacheReadTokens += u.CacheReadTokens
c.h.cacheWriteTokens += u.CacheWriteTokens
c.mgr.mu.Unlock()
}
func (c *runCtx) Subtask() string { return c.subTask }
@@ -286,8 +300,59 @@ func (m *Manager) sessionAllows(conv, tool string) bool {
return m.sessionAllow[conv][tool]
}
// convAllowStore 把 Manager 的会话放行表呈现为 agentpolicy.SessionAllows 端口。
// 键控保持既有口径:会话 × 工具名(同名调用不再询问),不区分参数。
type convAllowStore struct {
mgr *Manager
conv string
}
func (s convAllowStore) Remember(name, args string) {
s.mgr.rememberSessionAllow(s.conv, name)
}
func (s convAllowStore) Allowed(name, args string) bool {
return s.mgr.sessionAllows(s.conv, name)
}
// toolConfirmCard 是工具确认 HITL 卡的统一形态:ExecuteTool 的画像确认与
// 内核 PermissionAsk 端口共用,两条路径的选项与口径保持一致。
func toolConfirmCard(name, args string) core.HITLCard {
var argsPreview string
if len(args) > 0 {
argsPreview = truncate(args, 600)
}
return core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", name), Kind: core.HITLKindConfirm, Detail: argsPreview,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
}
// PermissionAsk 把内核钩子的 ask 决策路由到 HITL 确认卡(N1 WP2 宿主侧)。
// 「本会话始终允许」沿用 ExecuteTool 的会话放行口径(会话 × 工具名)。
func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
resp, err := c.AskHITL(toolConfirmCard(call.Name, call.Arguments))
if err != nil {
return nruntime.AskVerdict{Reason: "hitl aborted: " + err.Error()}
}
switch resp.ChoiceID {
case "session":
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
return nruntime.AskVerdict{Allow: true}
case "yes":
return nruntime.AskVerdict{Allow: true}
default:
return nruntime.AskVerdict{Reason: "用户取消了本次工具调用"}
}
}
func (c *runCtx) Tools() []core.ToolDesc {
list := c.mgr.registry.List()
list := tools.FilterExecDisabled(c.mgr.registry.List())
out := make([]core.ToolDesc, 0, len(list))
fs := c.eff.GetBool("runtime.fsTools", false)
code := c.eff.GetBool("runtime.codeOrchestration", false)
@@ -678,29 +743,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
}
desc, _, _ := c.mgr.registry.Get(call.Name)
effect := agentpolicy.Decide(agentpolicy.Request{
Mode: agentpolicy.Normalize(c.permissionMode), Name: call.Name, Args: string(call.Arguments),
Traits: agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk},
SessionAllow: c.mgr.sessionAllows(c.run.ConversationID, call.Name),
}, nil)
// N1 WP3:判定走 Policy 对象,会话放行经 SessionAllows 端口(键控口径不变:会话 × 工具名)。
pol := agentpolicy.Policy{
Mode: agentpolicy.Normalize(c.permissionMode),
SessionAllows: convAllowStore{mgr: c.mgr, conv: c.run.ConversationID},
}
effect := pol.Decide(call.Name, string(call.Arguments),
agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk})
if effect == agentpolicy.EffectDeny {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)}
}
if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) {
var argsPreview string
if len(call.Arguments) > 0 {
argsPreview = truncate(string(call.Arguments), 600)
}
card := core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm, Detail: argsPreview,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
resp, err := c.AskHITL(card)
resp, err := c.AskHITL(toolConfirmCard(call.Name, string(call.Arguments)))
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
@@ -866,7 +920,7 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
if m.wfRunner != nil {
return m.wfRunner
}
return runtime.WorkflowRunner{}
return runtime.CompiledWorkflowRunner{}
}
if eff.GetBool("runtime.mock", false) {
return m.mock
@@ -1035,6 +1089,10 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.LastPromptTokens = rc.h.lastPromptTokens
usage.LastCompletionTokens = rc.h.lastCompletionTokens
usage.CacheReadTokens = rc.h.cacheReadTokens
usage.CacheWriteTokens = rc.h.cacheWriteTokens
m.mu.Unlock()
if m.shuttingDown.Load() && status == core.RunCancelled {
status = core.RunInterrupted
@@ -1227,12 +1285,21 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
answer = resp.FreeText
trimmed := strings.TrimSpace(resp.FreeText)
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
// 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。
pivot := false
pivotReason := ""
if strings.HasPrefix(trimmed, "/") {
pivot, pivotReason = true, "command"
} else if m.gate != nil {
if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
if rec, ok := m.gate.(interface {
Recognize(ctx context.Context, input string) intentgate.MatchResult
}); ok {
res := rec.Recognize(context.Background(), trimmed)
dec := route.Decide(res, route.Env{})
if dec.StartFound {
pivot, pivotReason = true, "intent:" + res.ID
}
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
pivot, pivotReason = true, "intent:"+topID
}
}
@@ -1303,6 +1370,8 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
if err := m.st.CreateRun(&child); err != nil {
return nil, nil, err
}
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
m.writeSubagentRequest(child, agentID, name, task, hint, detach)
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
base := parentCtx
@@ -1339,7 +1408,17 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
return runner.Execute(rc)
}()
// 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
m.mu.Lock()
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.LastPromptTokens = rc.h.lastPromptTokens
usage.LastCompletionTokens = rc.h.lastCompletionTokens
usage.CacheReadTokens = rc.h.cacheReadTokens
usage.CacheWriteTokens = rc.h.cacheWriteTokens
m.mu.Unlock()
status := core.RunCompleted
summary := "完成"
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
@@ -1368,6 +1447,8 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
m.mu.Lock()
subParts := append([]core.Part{}, rc.h.assistant...)
m.mu.Unlock()
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
m.writeSubagentTranscript(child, subParts, string(status))
fullText := partsText(subParts)
if len(subParts) > 0 {
_ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
@@ -1432,6 +1513,15 @@ func (m *Manager) decBackground(convID string) {
}
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
// 先落库(重启重投凭据),再进内存热队列;落库失败不丢内存路径。
if n.id == "" {
n.id = store.NewID("ntc")
}
if err := m.st.AppendNotice(store.Notice{
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
}); err != nil {
log.Printf("[runmgr] notice persist %s: %v", convID, err)
}
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.notices == nil {
@@ -1440,6 +1530,31 @@ func (m *Manager) enqueueNotice(convID string, n bgNotice) {
m.notices[convID] = append(m.notices[convID], n)
}
// LoadPendingNotices 启动时把未投递的通知装回内存队列并尝试投递
// (N2 WP5:重启重投)。投递遵循既有空闲语义(忙则等下一轮触发)。
func (m *Manager) LoadPendingNotices() {
items, err := m.st.ListNotices()
if err != nil {
log.Printf("[runmgr] notice load: %v", err)
return
}
byConv := map[string]bool{}
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
for _, it := range items {
m.notices[it.ConversationID] = append(m.notices[it.ConversationID], bgNotice{
id: it.ID, owner: it.Owner, name: it.Name, payload: it.Payload,
})
byConv[it.ConversationID] = true
}
m.noticeMu.Unlock()
for convID := range byConv {
m.deliverNotices(convID)
}
}
// deliverNotices 在会话空闲时把全部待投递结果合并成一条 system 消息,并启动新的父回合。
// 会话忙、或启动时发现忙,就把结果放回队列。
func (m *Manager) deliverNotices(convID string) {
@@ -1484,6 +1599,17 @@ func (m *Manager) deliverNotices(convID string) {
}
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)
}
}
@@ -1544,6 +1670,16 @@ func (m *Manager) InterruptRunning() {
h.cancel()
}
for _, id := range ids {
// 子代理 run 先把已收集的部分转录落定(中断可续跑的现场)。
if run, err := m.st.GetRunByID(id); err == nil && run.Kind == core.RunSubagent {
m.mu.Lock()
var parts []core.Part
if h, ok := m.active[id]; ok {
parts = append(parts, h.assistant...)
}
m.mu.Unlock()
m.writeSubagentTranscript(run, parts, "interrupted")
}
m.sealRunInterrupted(id)
}
}
+224
View File
@@ -0,0 +1,224 @@
package runmgr
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// ---- N2 WP5:子代理独立转录、中断续跑、通知持久化 ----
// 派生即落 request.json;完成时 transcript.jsonl + done.json 落定。
func TestSubagentTranscriptFilesWritten(t *testing.T) {
c, _ := newSpawnEnv(t, &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果文本"}}})
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
res, err := c.SpawnSubagent("检索", "查资料", "提示语")
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(ws, c.run.Owner, c.run.ConversationID, "subagents", res.AgentRun)
reqBody, err := os.ReadFile(filepath.Join(dir, "request.json"))
if err != nil {
t.Fatalf("request.json: %v", err)
}
if !strings.Contains(string(reqBody), "查资料") {
t.Fatalf("request.json 内容:%s", reqBody)
}
trBody, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl"))
if err != nil || !strings.Contains(string(trBody), "结果文本") {
t.Fatalf("transcript.jsonl:%v %s", err, trBody)
}
doneBody, err := os.ReadFile(filepath.Join(dir, "done.json"))
if err != nil || !strings.Contains(string(doneBody), "completed") {
t.Fatalf("done.json:%v %s", err, doneBody)
}
}
// 中断子 run 凭 request.json 续跑:同任务重派、hint 带前次转录路径、
// resumed.json 防重复续跑。
func TestResumeInterruptedChildrenRespawns(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)
// 父 run 落库(续跑要经 store 取回派生上下文)。
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
// 造一个中断的子 run 现场(等价于崩溃前的派生)。
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)
}
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)
}
// resumed.json 幂等:第二次调用不再新增。
c.mgr.ResumeInterruptedChildren()
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids2) != len(ids) {
t.Fatalf("重复续跑:%v → %v", ids, ids2)
}
}
// 通知先落库后投递;模拟重启后 LoadPendingNotices 重投并清库。
func TestNoticesPersistAndRedeliverAfterRestart(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "后台结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
// 会话占线:通知只能入库不能投递。
if err := c.mgr.claimConversation(c.run.ConversationID); err != nil {
t.Fatal(err)
}
if _, err := c.SpawnBackground("agent.sub.general", "甲", "任务甲", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
items, err := st.ListNotices()
return err == nil && len(items) == 1
})
// 模拟重启:同一 store 上的新 Manager 只接通知面(会话仍忙时不应丢)。
c.mgr.releaseConversation(c.run.ConversationID)
m2 := New(st, c.mgr.bus, nil, c.mgr.profiles, c.mgr.settings, noopRunner{}, runner, nil, nil)
m2.LoadPendingNotices()
waitUntil(t, func() bool {
items, err := st.ListNotices()
return err == nil && len(items) == 0
})
texts := systemTexts(t, c)
if len(texts) == 0 || !strings.Contains(texts[len(texts)-1], "后台结果") {
t.Fatalf("重投的通知正文缺失:%v", texts)
}
}
// 适配层 Background 分支:Request.Background=true → SpawnBackground(语义
// 与产品 delegate 的 background 参数一致)。在本包内用 runCtx 直验。
func TestSpawnBackgroundRecordsDetach(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "bg"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
id, err := c.SpawnBackground("agent.sub.general", "后台", "任务", "")
if err != nil {
t.Fatal(err)
}
run, err := c.mgr.st.GetRunByID(id)
if err != nil {
t.Fatal(err)
}
if run.Kind != core.RunSubagent || run.Status != core.RunCompleted && run.Status != core.RunRunning {
t.Fatalf("run = %+v", run)
}
}
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
// completed 终态挡续跑。
func TestResumeAfterGracefulInterruptMarker(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)
// 模拟优雅停机:partial 转录 + done.json{status:"interrupted"}。
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "半截现场"}}, "interrupted")
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before)+1 {
t.Fatalf("优雅停机现场应被续跑一次:before=%v after=%v", before, ids)
}
c.mgr.ResumeInterruptedChildren()
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids2) != len(ids) {
t.Fatalf("重复续跑:%v → %v", ids, ids2)
}
}
func TestResumeSkipsCompletedMarker(t *testing.T) {
c, st := newSpawnEnv(t, &gateRunner{})
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)
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "已完成"}}, "completed")
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before) {
t.Fatalf("completed 终态不得续跑:before=%v after=%v", before, ids)
}
}
// N3 WP6:子代理记账链——AddUsage 累计拆分字段,finishChild 合并进 child.Usage。
func TestAddUsageAccumulatesSplitFields(t *testing.T) {
c, _ := newSpawnEnv(t, &gateRunner{})
c.h = &runHandle{}
c.AddUsage(core.Usage{PromptTokens: 100, CompletionTokens: 10, LastPromptTokens: 100, LastCompletionTokens: 10, CacheReadTokens: 50, CacheWriteTokens: 4})
c.AddUsage(core.Usage{PromptTokens: 60, CompletionTokens: 6, LastPromptTokens: 60, LastCompletionTokens: 6, CacheReadTokens: 20, CacheWriteTokens: 2})
c.mgr.mu.Lock()
defer c.mgr.mu.Unlock()
if c.h.promptTokens != 160 || c.h.completionTokens != 16 ||
c.h.lastPromptTokens != 60 || c.h.lastCompletionTokens != 6 ||
c.h.cacheReadTokens != 70 || c.h.cacheWriteTokens != 6 {
t.Fatalf("记账链:%+v", c.h)
}
}
+174
View File
@@ -0,0 +1,174 @@
package runmgr
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
core "agent-system-core"
)
// subagent_transcript.go — 子代理独立转录与中断续跑(N2 WP5)。
// 每个子 run 一个目录:<workspaceRoot>/<owner>/<conv>/subagents/<runID>/
// request.json 派生请求(agentID/name/task/hint/detach),续跑的重建凭据
// transcript.jsonl 按 part 追加的转录(完成/中断时落定)
// done.json 终态标记(resume 幂等:有它不再续跑)
// resumed.json 已续跑标记(指向新 run)
// subagentDir 返回子 run 的转录目录(未配置 workspaceRoot 时为空,写盘静默跳过)。
func (m *Manager) subagentDir(owner, convID, runID string) string {
if m.workspaceRoot == "" {
return ""
}
return filepath.Join(m.workspaceRoot, filepath.Clean(owner), filepath.Clean(convID), "subagents", filepath.Clean(runID))
}
// subagentRequest 是 request.json 的内容。
type subagentRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Task string `json:"task"`
Hint string `json:"hint,omitempty"`
Detach bool `json:"detach"`
CreatedAt string `json:"createdAt"`
}
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
// 不阻断派生——转录是辅助面)。
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
return
}
body, _ := json.MarshalIndent(subagentRequest{
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, "", " ")
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
}
// 转录文件先建(append 语义清晰可见)。
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
}
}
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
// status 是终态(completed/failed/cancelled/interrupted)。
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
return
}
enc := json.NewEncoder(f)
for i, p := range parts {
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
}
_ = f.Close()
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
}
}
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
// 缺标记本身就是中断现场)。
func doneStatus(dir string) string {
body, err := os.ReadFile(filepath.Join(dir, "done.json"))
if err != nil {
return "interrupted"
}
var done struct {
Status string `json:"status"`
}
if json.Unmarshal(body, &done) != nil || done.Status == "" {
return "interrupted"
}
return done.Status
}
// readSubagentRequest 读 request.json(无文件返回 nil, nil)。
func readSubagentRequest(dir string) (*subagentRequest, error) {
body, err := os.ReadFile(filepath.Join(dir, "request.json"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var req subagentRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("request.json: %w", err)
}
return &req, nil
}
// ResumeInterruptedChildren 重启后扫描被中断的子代理 run:有派生请求且
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
func (m *Manager) ResumeInterruptedChildren() {
runs, err := m.st.ListInterruptedSubagentRuns()
if err != nil {
log.Printf("[runmgr] resume scan: %v", err)
return
}
for _, run := range runs {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
continue
}
// done.json 语义分流(N3 WP1):终态(completed/failed/cancelled)挡续跑;
// interrupted 不挡——优雅停机与崩溃都是可续跑现场。
if doneStatus(dir) != "interrupted" || fileExists(filepath.Join(dir, "resumed.json")) {
continue
}
req, err := readSubagentRequest(dir)
if err != nil {
log.Printf("[runmgr] resume read %s: %v", run.ID, err)
continue
}
if req == nil {
continue // WP5 之前的旧 run 没有重建凭据,保持 interrupted 终态
}
parent, err := m.st.GetRunByID(run.ParentRunID)
if err != nil {
log.Printf("[runmgr] resume parent %s: %v", run.ParentRunID, err)
continue
}
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
if err != nil {
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
continue
}
marker, _ := json.Marshal(map[string]string{
"newRun": newID,
"resumedAt": time.Now().UTC().Format(time.RFC3339),
})
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
}
log.Printf("[runmgr] resumed subagent %s as %s", run.ID, newID)
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+64 -45
View File
@@ -5,16 +5,18 @@ import (
"fmt"
"math"
"net/http"
"regexp"
"strings"
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/quality"
agentmemory "agent-native/runtime/memory"
core "agent-system-core"
)
@@ -203,9 +205,10 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
stream := l.Stream
if stream == nil {
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
// 此处不再显式包装(防双重)。
client := openLLMClient(route.Base, route.Key)
retries := int(eff.GetNumber("runtime.llmRetries", 2))
stream = agentllm.WithRetry(client.Stream, retries, nil)
stream = client.Stream
}
// 知识注入:当前消息包含 @knowledge 引用时检索注入(最小实现:显式引用)。
@@ -227,9 +230,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
}
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) }
if ms, ok := rc.(interface{ Memories() []core.MemoryEntry }); ok {
sys = ctxasm.InjectMemory(sys, ms.Memories())
}
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
tail := ctxasm.Tail{
Instructions: eff.GetString("context.userInstructions", ""),
@@ -398,6 +401,28 @@ func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key strin
return 0
}
// memoryStore 是记忆端口的最小面(agent-native runtime/memory.Store 的本地别名;
// 组合根经 runCtx 注入,runmgr 的 storeMemory 实现之)。
type memoryStore = agentmemory.Store
// retryAttemptsOf 把 runtime.llmRetries 的设置语义(0=不重试,N=N 次)
// 映射到库 Config.RetryAttempts(0=默认 2,负值=关,正数=次数)。
func retryAttemptsOf(eff core.EffectiveSettings) int {
n := int(eff.GetNumber("runtime.llmRetries", 2))
if n <= 0 {
return -1
}
return n
}
// agentNameOf 是 OTel gen_ai.agent.name 的取值(画像 ID,空回退 agent-system)。
func agentNameOf(prof core.AgentProfile) string {
if strings.TrimSpace(prof.ID) != "" {
return prof.ID
}
return "agent-system"
}
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
func openLLMClient(base, key string) *agentllm.Client {
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
@@ -453,61 +478,55 @@ func orDefault(s, def string) string {
return s
}
const maxDeliverableGuards = 2
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。"
const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)"
const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。"
var (
artIDRe = regexp.MustCompile(`art_[0-9a-f]{12}`)
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
)
// claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
block, _ := deliverableGate(body, produced, rc)
block, _ := deliverableGateOf(body, produced, rc)
return block
}
// deliverableGate 判断终轮正文是否在没有对应工件的情况下声称交付。
// 返回值的形状与 Stop 门禁一致,P1 平移进内核时不用再改调用方。
// 引用本轮已产出、或项目里确实存在的工件,不算谎称;正文里的伪造调用标记始终拦截。
func deliverableGate(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
if strings.TrimSpace(body) == "" {
return false, ""
}
if forgedCallRe.MatchString(body) {
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
}
producedIDs := map[string]bool{}
for _, ref := range produced {
if ref != nil && ref.ArtifactID != "" {
producedIDs[ref.ArtifactID] = true
}
}
knownExisting := false
for _, id := range artIDRe.FindAllString(body, -1) {
if producedIDs[id] {
// deliverableGateOf 把产品工件存取投影成库端口后调用下沉的 ClaimGate(N1 WP5)。
func deliverableGateOf(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
return gates.ClaimGate(body, artifactRefsOf(produced), artifactLookupOf(rc))
}
// artifactRefsOf 把 core.ArtifactRef 投影为库的最小形态。
func artifactRefsOf(refs []*core.ArtifactRef) []gates.ArtifactRef {
out := make([]gates.ArtifactRef, 0, len(refs))
for _, ref := range refs {
if ref == nil || ref.ArtifactID == "" {
continue
}
if _, _, ok := rc.Artifact(id); ok {
knownExisting = true
continue
out = append(out, gates.ArtifactRef{ID: ref.ArtifactID, Name: ref.Name, Kind: ref.Kind})
}
return out
}
// artifactLookupOf 把 rc.Artifact 投影为库的工件存取端口。
func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
return func(id string) (string, []byte, bool) {
art, content, ok := rc.Artifact(id)
if !ok {
return "", nil, false
}
return true, "回复引用了不存在的工件"
return art.Name, content, true
}
if len(producedIDs) > 0 || knownExisting {
return false, ""
}
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
func qualityScorer() gates.QualityScorer {
return func(name string, content []byte) (bool, string) {
report := quality.ScoreBytes(name, content)
if report.Passed {
return true, ""
}
return false, strings.Join(report.Violations, ";")
}
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
}
return false, ""
}
func summarize(out string, err error) string {
+58 -27
View File
@@ -3,18 +3,22 @@ package runtime
import (
"context"
"encoding/json"
"log"
"path/filepath"
"fmt"
"strings"
"sync"
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/gates"
"agent-native/runtime/hooks"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/reminders"
agentsession "agent-native/runtime/session"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
core "agent-system-core"
)
@@ -26,6 +30,7 @@ type directRun struct {
rc RunCtx
eff core.EffectiveSettings
cardflow bool
memoryStore memoryStore
finalParts []core.Part
explicitCard bool
deliverables []*core.ArtifactRef
@@ -51,9 +56,8 @@ type directRun struct {
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
checklistReminders int
progress *gates.ProgressReminder
checks []core.CheckPart
notes *reminders.Bus
servedModel string
switches []string
switchMu sync.Mutex
@@ -90,6 +94,10 @@ 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) error {
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
st.memoryStore = ms.StoreMemory()
}
st.loadChecklist()
if msg, ok := lastAddressed(rc.History()); ok && msg.Role == core.RoleSystem {
var text string
@@ -159,6 +167,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
}
window, reserve, buffer := contextWindow(st.eff)
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
// Session(旧行为,不阻断对话)。
var sessStore *agentsession.Store
if sandbox := conversationSandbox(rc); sandbox != "" {
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
sessStore = opened
} else {
log.Printf("[runtime] open session transcript: %v", oerr)
}
}
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
@@ -173,6 +193,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: model,
History: history,
Session: sessStore,
JournalOnly: sessStore != nil,
Tools: reg,
Stream: wrapped,
MaxTurns: maxTurns,
@@ -186,7 +208,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
OnEvent: st.onEvent,
Hooks: bus,
Reminders: st.reminders,
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
Memory: st.memoryStore,
RetryAttempts: retryAttemptsOf(eff),
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
return st.runTool(call.Name, call.Arguments)
},
@@ -251,6 +277,9 @@ func (st *directRun) noteToolDelta(ev agentllm.Event) {
func (st *directRun) onEvent(ev nruntime.Event) {
switch ev.Type {
case "auto_retry_start":
// 重试可观测(N3 WP6):沿用 process part 形态。
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "模型在输出前失败,正在重试(" + ev.Text + ")", Process: true})
case "turn_start":
st.responding = false
st.acting = false
@@ -290,6 +319,11 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.usageTotal.PromptTokens += ev.PromptTokens
st.usageTotal.CompletionTokens += ev.CompletionTokens
st.usageTotal.TotalTokens += ev.TotalTokens
// 计费拆分(N3 WP6):上一请求覆盖、缓存命中累计。
st.usageTotal.LastPromptTokens = ev.PromptTokens
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
hasCalls := len(ev.Message.ToolCalls) > 0
if st.turnThink.Len() > 0 {
@@ -347,12 +381,12 @@ func (st *directRun) onEvent(ev nruntime.Event) {
}
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
if block, why := deliverableGate(st.turnBody, st.deliverables, st.rc); block {
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
st.demoteLastText()
if st.eff.GetBool("runtime.verify", false) {
st.addVerifyNote(why)
}
if info.Continuations < maxDeliverableGuards {
if info.Continuations < gates.MaxDeliverableGuards {
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)}
}
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
@@ -360,7 +394,8 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
if !st.eff.GetBool("runtime.verify", false) {
return nruntime.StopDecision{}
}
if reasons := checkDeliverables(st.rc, st.deliverables); len(reasons) > 0 {
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
return st.continueOrNotice(info, strings.Join(reasons, ";"))
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
@@ -424,7 +459,7 @@ func (st *directRun) addCheck(passed bool, reason string) {
}
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
if info.Continuations < maxVerifyContinuations {
if info.Continuations < gates.MaxVerifyContinuations {
st.demoteLastText()
st.addVerifyNote(reason)
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
@@ -594,7 +629,9 @@ func (st *directRun) runRegisteredWorkflow(args string) string {
if !ok {
return "未注册的工作流 " + a.Workflow
}
res, err := (WorkflowRunner{}).run(st.rc, registeredWorkflowLaunch(st.rc, def, a.Inputs))
// Q3 WP2:注册工作流经编译图执行(P4 上限在编译入口强制)。
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc,
registeredWorkflowLaunch(st.rc, def, a.Inputs), wfcompile.RunOptions{})
st.pinSubagentArtifacts(res.Summary)
if err != nil {
if res.Summary != "" {
@@ -606,31 +643,25 @@ func (st *directRun) runRegisteredWorkflow(args string) string {
}
func (st *directRun) runScriptWorkflow(body string) string {
text, arts, err := runModelScript(st.rc, WorkflowRunner{}, body)
var ids []string
for _, art := range arts {
if art != nil {
ids = append(ids, string(art.ArtifactID))
}
}
if len(ids) > 0 {
if text != "" {
text += "\n"
}
text += "工件:" + strings.Join(ids, "、")
}
st.pinSubagentArtifacts(text)
// Q3 WP2:模型脚本包成单步工作流走编译图——runtime.workflowScripts
// 开关与执行前确认由 wfcompile 宿主处理器持有(P4/legacy 同款:模型脚本
// 只有脚本级确认门,不叠加计划确认)。
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc, modelScriptLaunch(body),
wfcompile.RunOptions{ModelScript: true})
st.pinSubagentArtifacts(res.Summary)
if err != nil {
if text != "" {
return text + "\n" + err.Error()
if res.Summary != "" {
return res.Summary + "\n" + err.Error()
}
return err.Error()
}
return text
return res.Summary
}
func (st *directRun) runPlannedWorkflow(goal string) string {
res, err := planWorkflow(st.rc, goal, WorkflowRunner{})
// Q3 WP2:规划分支——规划子代理产出 WorkflowDef,动态上限编译,
// 执行前强制计划确认(legacy planWorkflow 同款流)。
res, err := planCompiledWorkflow(st.rc, goal, CompiledWorkflowRunner{})
st.pinSubagentArtifacts(res.Summary)
if err != nil {
if res.Summary != "" {
@@ -727,7 +758,7 @@ func (st *directRun) pinSubagentArtifacts(text string) {
seen[ref.ArtifactID] = true
}
}
for _, id := range artIDRe.FindAllString(text, -1) {
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
if seen[id] {
continue
}
+166
View File
@@ -0,0 +1,166 @@
package runtime
import (
"context"
"path/filepath"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
agentsession "agent-native/runtime/session"
core "agent-system-core"
)
// ---- N3 WP2:产品会话路径持久化(验收① 产品层机器证据)----
func textTurns(texts ...string) [][]agentllm.Event {
out := make([][]agentllm.Event, 0, len(texts))
for _, t := range texts {
out = append(out, []agentllm.Event{
{Kind: agentllm.KindTextDelta, Text: t},
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: t, StopReason: agentllm.StopEnd}},
})
}
return out
}
// 杀窗口形态的种子转录:新 run 前配对修复成立,且 run 正常产出。
func TestDirectRunPairsAfterKillSeed(t *testing.T) {
sandbox := t.TempDir()
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, m := range []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "sys"},
{Role: agentllm.RoleUser, Content: "早前"},
{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{{ID: "c1", Name: "run_python", Arguments: `{}`}}},
} {
if err := sess.Append(m); err != nil {
t.Fatal(err)
}
}
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
history: []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "还在吗"}}},
},
}
r := &LLMRunner{Stream: scriptStream(textTurns("还在"), nil)}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
loaded, err := sess.Load()
if err != nil {
t.Fatal(err)
}
var paired bool
for _, m := range loaded {
if m.Role == agentllm.RoleTool && m.ToolCallID == "c1" {
paired = true
if !m.IsError || !strings.Contains(m.Content, "进程中断") {
t.Fatalf("兜底配对形态不对:%+v", m)
}
}
}
if !paired {
t.Fatalf("杀窗口调用未配对:%#v", loaded)
}
}
// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、
// 不丢不重);journal 不吞种子历史。
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
sandbox := t.TempDir()
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
history: []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "历史一"}}},
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "回复一"}}},
{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "最新"}}},
},
}
var requests [][]agentllm.Message
base := scriptStream(textTurns("一答", "二答"), nil)
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
requests = append(requests, append([]agentllm.Message{}, msgs...))
return base(ctx, model, msgs, tools, opts)
}}
if err := r.Execute(rc); err != nil {
t.Fatalf("first Execute: %v", err)
}
// 第二跑:同一 rc(新 user 消息由 history 尾部提供)。
rc.history = append(rc.history, core.Message{ID: "m4", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "再来"}}})
if err := r.Execute(rc); err != nil {
t.Fatalf("second Execute: %v", err)
}
if len(requests) < 2 {
t.Fatalf("stream 调用数 = %d", len(requests))
}
for i, req := range requests {
count := 0
for _, m := range req {
if m.Content == "历史一" {
count++
}
}
if count != 1 {
t.Fatalf("第 %d 跑历史中「历史一」必须恰好一次,得到 %d", i+1, count)
}
}
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
if err != nil {
t.Fatal(err)
}
loaded, err := sess.Load()
if err != nil {
t.Fatal(err)
}
for _, m := range loaded {
if m.Content == "历史一" {
t.Fatalf("journal 不得吞种子历史:%#v", loaded)
}
}
}
// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)----
// 端口注入的 [记忆] 块到达请求(迁移前经组装侧 InjectMemory 同文案形态)。
func TestDirectRunInjectsMemoryBlock(t *testing.T) {
var gotReq []agentllm.Message
base := scriptStream(textTurns("收到"), nil)
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
memEntries: []memEntry{
{typ: "fact", content: "项目用 Go 1.27"},
{typ: "preference", content: "喜欢简洁回复"},
},
}
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
gotReq = append([]agentllm.Message{}, msgs...)
return base(ctx, model, msgs, tools, opts)
}}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var block string
for _, m := range gotReq {
if strings.Contains(m.Content, "[记忆]") {
block = m.Content
}
}
if block == "" {
t.Fatalf("记忆块缺失:%#v", gotReq)
}
// 形态对照(与 ctxasm.InjectMemory 同文):[记忆]\n- (typ) content,确定性排序。
if !strings.Contains(block, "- (fact) 项目用 Go 1.27") ||
!strings.Contains(block, "- (preference) 喜欢简洁回复") {
t.Fatalf("记忆块形态不对:%q", block)
}
if strings.Index(block, "(fact)") > strings.Index(block, "(preference)") {
t.Fatalf("排序不确定:%q", block)
}
}
+51 -5
View File
@@ -2,6 +2,8 @@ package runtime
import (
"context"
agentmemory "agent-native/runtime/memory"
"errors"
"fmt"
"strings"
@@ -39,6 +41,9 @@ type fakeRunCtx struct {
history []core.Message // 非 nil 时覆盖默认历史
bgID string
bgErr error
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
}
@@ -58,6 +63,15 @@ func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
return nil
}
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
func (f *fakeRunCtx) AddUsage(u core.Usage) {
f.usageTotal.PromptTokens += u.PromptTokens
f.usageTotal.CompletionTokens += u.CompletionTokens
f.usageTotal.TotalTokens += u.TotalTokens
f.usageTotal.LastPromptTokens = u.LastPromptTokens
f.usageTotal.LastCompletionTokens = u.LastCompletionTokens
f.usageTotal.CacheReadTokens += u.CacheReadTokens
f.usageTotal.CacheWriteTokens += u.CacheWriteTokens
}
func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
return core.HITLResponse{}, fmt.Errorf("not implemented")
}
@@ -94,6 +108,34 @@ func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID,
}
return f.bgID, nil
}
func (f *fakeRunCtx) ConversationSandbox() string {
if f.sandbox != "" {
return f.sandbox
}
return ""
}
// memEntry 是测试用记忆条目(避免 fake 引入库类型)。
type memEntry struct{ typ, content string }
type fakeMemStore struct{ entries []memEntry }
func (s fakeMemStore) Recall(_ context.Context, q agentmemory.RecallQuery) ([]agentmemory.Entry, error) {
out := make([]agentmemory.Entry, 0, len(s.entries))
for _, e := range s.entries {
out = append(out, agentmemory.Entry{Type: e.typ, Content: e.content})
}
return out, nil
}
func (s fakeMemStore) Append(_ context.Context, e agentmemory.Entry) error { return nil }
func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
if f.memEntries == nil {
return nil
}
return fakeMemStore{entries: f.memEntries}
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" }
@@ -784,7 +826,8 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
if calls != 2 {
t.Fatalf("stream calls = %d, want 2", calls)
}
var placeholder bool
// N6(N3 WP1):伪造正文不得作为 assistant 的话术回放;更正以 user
// 消息出现(不再改写 assistant)。
for _, m := range replay {
if m.Role != agentllm.RoleAssistant {
continue
@@ -792,12 +835,15 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
if strings.Contains(m.Content, "run_python") {
t.Fatalf("forged body was replayed: %q", m.Content)
}
if strings.Contains(m.Content, "已作废") {
placeholder = true
}
var correction bool
for _, m := range replay {
if m.Role == agentllm.RoleUser && strings.Contains(m.Content, "已作废") {
correction = true
}
}
if !placeholder {
t.Fatalf("correction replay missing placeholder, got %#v", replay)
if !correction {
t.Fatalf("correction replay missing user note, got %#v", replay)
}
texts := textParts(rc.assistant)
if len(texts) != 2 || !texts[0].Process || texts[1].Process {
+4 -3
View File
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
@@ -230,11 +231,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
}
func TestParseVerdictToleratesFence(t *testing.T) {
got, err := parseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
got, err := gates.ParseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
if err != nil || got.Passed || got.Reason != "不够" {
t.Fatalf("verdict = %+v %v", got, err)
}
if _, err := parseVerdict(`{"reason":"no passed"}`); err == nil {
if _, err := gates.ParseVerdict(`{"reason":"no passed"}`); err == nil {
t.Fatal("missing passed should fail")
}
}
@@ -392,7 +393,7 @@ func TestFalseDeliverableFixtures(t *testing.T) {
}
for _, c := range doc.Cases {
t.Run(c.Name, func(t *testing.T) {
block, reason := deliverableGate(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
block, reason := deliverableGateOf(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
if !block || strings.TrimSpace(reason) == "" {
t.Fatalf("gate = %v %q", block, reason)
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"agent-system-backend/internal/docgen"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
@@ -214,7 +215,8 @@ func (m *MockRunner) Execute(rc RunCtx) error {
}
case strings.Contains(user, "#workflow"):
res, err := (WorkflowRunner{}).run(rc, workflow.Launch{Def: mockWorkflow()})
// Q3 WP2:演示工作流同走编译图(纯宿主步,不依赖模型后端)。
res, err := (CompiledWorkflowRunner{}).runLaunch(rc, workflow.Launch{Def: mockWorkflow()}, wfcompile.RunOptions{})
if err != nil {
if res.Summary != "" {
addText(res.Summary)
+35
View File
@@ -0,0 +1,35 @@
package runtime
import (
"context"
"testing"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
)
// PermissionAskOf 只在宿主实现内核级询问时接线;否则返回 nil,
// 内核保持「ask 按拒绝处理」的旧行为。
func TestPermissionAskOfOptional(t *testing.T) {
type plain struct{ RunCtx }
if got := PermissionAskOf(plain{}); got != nil {
t.Fatal("plain rc returned a non-nil ask port, want nil")
}
}
func TestPermissionAskOfWired(t *testing.T) {
type asker struct{ RunCtx }
wired := PermissionAskOf(askStub{})
if wired == nil {
t.Fatal("wired rc returned nil")
}
if v := wired(context.Background(), agentllm.ToolCall{Name: "run_shell"}); !v.Allow {
t.Fatalf("verdict = %+v", v)
}
}
type askStub struct{ RunCtx }
func (askStub) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
return nruntime.AskVerdict{Allow: true}
}
+18
View File
@@ -5,6 +5,9 @@ package runtime
import (
"context"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
@@ -56,3 +59,18 @@ type Runner interface {
Name() string
Execute(rc RunCtx) error
}
// PermissionAskOf 把宿主的内核级权限询问(runmgr 的 HITL 确认卡)接成
// nruntime.Config.PermissionAsk 端口;宿主未实现时返回 nil,内核保持
// 「ask 按拒绝处理」的旧行为(N1 WP2 装配;直连/子代理/小队共用)。
// 入参为 any:直连/子代理传 RunCtx,小队装配传 squadadapter.Ctx——
// 判定只依赖可选的 PermissionAsk 方法,不要求实现整个 RunCtx。
func PermissionAskOf(rc any) func(context.Context, agentllm.ToolCall) nruntime.AskVerdict {
asker, ok := rc.(interface {
PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict
})
if !ok {
return nil
}
return asker.PermissionAsk
}
+5 -1
View File
@@ -53,7 +53,11 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}})
case "usage":
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(core.Usage{PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens})
sink.AddUsage(core.Usage{
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
})
}
}
}
+20 -5
View File
@@ -108,8 +108,15 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
OutputReserve: reserve,
ContextBuffer: buffer,
OnEvent: st.onEvent,
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
RetryAttempts: retryAttemptsOf(rc.Settings()),
}, user)
// 子代理记账(N3 WP6):token 用量入账(此前只有 DurationMs)。
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(st.usageTotal)
}
if len(st.parts) == 0 {
st.parts = append(st.parts, core.Part{Type: core.PartText, Text: "(子代理未返回内容)"})
}
@@ -167,11 +174,12 @@ type subSlot struct {
}
type subRun struct {
rc RunCtx
parts []core.Part
queued []agentllm.ToolCall
inflight *subSlot
turnText strings.Builder
rc RunCtx
parts []core.Part
queued []agentllm.ToolCall
inflight *subSlot
turnText strings.Builder
usageTotal core.Usage
}
func (st *subRun) onEvent(ev nruntime.Event) {
@@ -182,6 +190,13 @@ func (st *subRun) onEvent(ev nruntime.Event) {
st.turnText.WriteString(ev.Text)
_ = st.rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: st.rc.Run().ID, Note: truncateRun(st.turnText.String(), 80)})
case "message_end":
st.usageTotal.PromptTokens += ev.PromptTokens
st.usageTotal.CompletionTokens += ev.CompletionTokens
st.usageTotal.TotalTokens += ev.TotalTokens
st.usageTotal.LastPromptTokens = ev.PromptTokens
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
hasCalls := len(ev.Message.ToolCalls) > 0
if body := strings.TrimSpace(st.turnText.String()); body != "" {
+71
View File
@@ -0,0 +1,71 @@
package runtime
import (
"context"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// ---- N3 WP6:计费拆分与重试收口(backend 侧)----
// retryAttemptsOf 映射:设置 0=不重试→-1;N→N;默认 2→2。
func TestRetryAttemptsMapping(t *testing.T) {
if got := retryAttemptsOf(core.EffectiveSettings{}); got != 2 {
t.Fatalf("默认应 2,得到 %d", got)
}
if got := retryAttemptsOf(core.EffectiveSettings{"runtime": map[string]any{"llmRetries": float64(0)}}); got != -1 {
t.Fatalf("设置 0 应映射为关闭(-1),得到 %d", got)
}
if got := retryAttemptsOf(core.EffectiveSettings{"runtime": map[string]any{"llmRetries": float64(5)}}); got != 5 {
t.Fatalf("设置 5 应映射为 5,得到 %d", got)
}
}
// 直连 run 的用量拆分:message_end 到达后 Last* 覆盖、cache 累计入账。
func TestDirectRunUsageSplitOnRecord(t *testing.T) {
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
stream := func(ctx context.Context, model string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 1)
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "x", StopReason: agentllm.StopEnd},
Usage: agentllm.Usage{PromptTokens: 120, CompletionTokens: 12, TotalTokens: 132, CacheReadTokens: 80, CacheWriteTokens: 6},
}
close(ch)
return ch
}
r := &LLMRunner{Stream: stream}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
u := rc.usageTotal
if u.PromptTokens != 120 || u.CompletionTokens != 12 ||
u.LastPromptTokens != 120 || u.LastCompletionTokens != 12 ||
u.CacheReadTokens != 80 || u.CacheWriteTokens != 6 {
t.Fatalf("用量拆分未入账:%+v", u)
}
}
// 子代理记账:subRun 的 message_end 累计 token 并经 AddUsage 入账。
func TestSubRunAccountsTokens(t *testing.T) {
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
stream := func(ctx context.Context, model string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 1)
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "子结果", StopReason: agentllm.StopEnd},
Usage: agentllm.Usage{PromptTokens: 66, CompletionTokens: 6, TotalTokens: 72, CacheReadTokens: 30},
}
close(ch)
return ch
}
r := &SubLLMRunner{Stream: stream}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
u := rc.usageTotal
if u.PromptTokens != 66 || u.CompletionTokens != 6 ||
u.LastPromptTokens != 66 || u.LastCompletionTokens != 6 || u.CacheReadTokens != 30 {
t.Fatalf("子代理用量未入账:%+v", u)
}
}
+27 -127
View File
@@ -1,27 +1,15 @@
package runtime
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/reminders"
"agent-system-backend/internal/quality"
core "agent-system-core"
)
const (
// maxVerifyContinuations 与内核 Stop 的 3 次上限对齐。
maxVerifyContinuations = 3
// reminderToolGap 连续这么多个工具轮没有更新计划或待办,就提醒一次。
reminderToolGap = 3
// maxChecklistReminders 每个 run 最多提醒这么多次。
maxChecklistReminders = 3
)
const verifyCapNotice = "本轮回复未通过终轮检查。"
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
@@ -37,61 +25,18 @@ func systemCheckReason(body string) string {
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
}
type verifyVerdict struct {
Passed bool `json:"passed"`
Reason string `json:"reason"`
NextAction string `json:"nextAction"`
}
// checkDeliverables 确认本轮登记的工件存在且非空。
// md、txt、docx 还要过 quality;其他类型只检查存在与非空。
func checkDeliverables(rc RunCtx, refs []*core.ArtifactRef) []string {
var reasons []string
for _, ref := range refs {
if ref == nil || ref.ArtifactID == "" {
reasons = append(reasons, "工件引用缺少 ID")
continue
}
art, content, ok := rc.Artifact(ref.ArtifactID)
name := ref.Name
if name == "" {
name = string(ref.ArtifactID)
}
if !ok {
reasons = append(reasons, name+" 不存在")
continue
}
if art.Name != "" {
name = art.Name
}
if len(content) == 0 {
reasons = append(reasons, name+" 内容为空")
continue
}
switch strings.ToLower(filepath.Ext(name)) {
case ".md", ".txt", ".docx":
report := quality.ScoreBytes(name, content)
if !report.Passed {
why := strings.Join(report.Violations, ";")
if why == "" {
why = "评分未通过"
}
reasons = append(reasons, name+":"+why)
}
}
}
return reasons
}
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (verifyVerdict, error) {
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
if stream == nil {
if route.Model == "" || route.Base == "" {
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
}
client := openLLMClient(route.Base, route.Key)
stream = client.Stream
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
stream = agentllm.WithFallback(
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
ChatFallbacks(rc.Settings()), nil)
}
model := route.Model
if model == "" {
@@ -107,16 +52,16 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
case agentllm.KindTextDelta:
b.WriteString(ev.Text)
case agentllm.KindError:
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
case agentllm.KindMessageEnd:
if b.Len() == 0 {
b.WriteString(ev.Message.Content)
}
}
}
verdict, err := parseVerdict(b.String())
verdict, err := gates.ParseVerdict(b.String())
if err != nil {
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
return gates.Verdict{}, fmt.Errorf("验证器返回无法解析")
}
return verdict, nil
}
@@ -170,41 +115,6 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
return b.String()
}
func parseVerdict(raw string) (verifyVerdict, error) {
s := stripFence(raw)
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end < start {
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
}
s = s[start : end+1]
var probe map[string]json.RawMessage
if err := json.Unmarshal([]byte(s), &probe); err != nil {
return verifyVerdict{}, err
}
if _, ok := probe["passed"]; !ok {
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
}
var verdict verifyVerdict
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
return verifyVerdict{}, err
}
return verdict, nil
}
func stripFence(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
s = strings.TrimPrefix(s, "```")
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
return s
}
func (st *directRun) loadChecklist() {
if src, ok := st.rc.(interface{ Todos() (core.TodoList, bool) }); ok {
if list, ok := src.Todos(); ok {
@@ -230,49 +140,39 @@ func (st *directRun) reminders(turn int) []string {
st.closedToolTurn = false
st.checklistTouched = false
}
if st.toolTurnsSince < reminderToolGap || st.checklistReminders >= maxChecklistReminders {
if st.toolTurnsSince < gates.ReminderToolGap {
return nil
}
text := st.unfinishedReminder()
if text == "" {
return nil
// N1 WP5:节流、封顶与文案走下沉的 ProgressReminder;
// 工具轮计数留在这里(它与 directRun 的事件流耦合)。
if st.progress == nil {
st.progress = &gates.ProgressReminder{}
}
if st.notes == nil {
st.notes = reminders.New(0, maxChecklistReminders)
todos, plans := st.unfinishedItems()
got := st.progress.Remind(todos, plans, time.Now())
if got != nil {
st.toolTurnsSince = 0
}
key := fmt.Sprintf("%d:%s", st.checklistReminders, text)
if !st.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, time.Now()) {
return nil
}
st.toolTurnsSince = 0
st.checklistReminders++
return []string{text}
return got
}
func (st *directRun) unfinishedReminder() string {
var lines []string
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
func (st *directRun) unfinishedItems() (todos, plans []string) {
for _, item := range st.todos.Items {
if item.Status == "done" {
continue
}
text := strings.TrimSpace(item.Text)
if text == "" {
continue
if text := strings.TrimSpace(item.Text); text != "" {
todos = append(todos, text)
}
lines = append(lines, "- 待办:"+text)
}
for _, step := range st.plan.Steps {
if step.Status == "done" || step.Status == "skipped" {
continue
}
text := strings.TrimSpace(step.Text)
if text == "" {
continue
if text := strings.TrimSpace(step.Text); text != "" {
plans = append(plans, text)
}
lines = append(lines, "- 计划:"+text)
}
if len(lines) == 0 {
return ""
}
return "计划和待办已连续 3 个工具轮没有更新。未完成项:\n" + strings.Join(lines, "\n") + "\n请更新计划或待办,或在回复里说明为什么先不做。"
return todos, plans
}
-423
View File
@@ -1,423 +0,0 @@
package runtime
import (
"encoding/json"
"fmt"
"strings"
"time"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
const workflowPlannerID = "agent.sub.workflow-plan"
// WorkflowRunner 执行启动时拷贝下来的工作流定义。
type WorkflowRunner struct {
// JudgeStream 注入验收模型。nil 时按轻量模型构造客户端。
JudgeStream agentllm.StreamFunc
// ScriptTimeout 覆盖脚本墙钟超时。0 表示 600 秒,超过 1800 秒会被截断。
ScriptTimeout time.Duration
}
func (WorkflowRunner) Name() string { return "workflow" }
type workflowSource interface {
Workflow() (workflow.Launch, bool)
}
type workflowOutcome struct {
Summary string
Parts []core.Part
Artifacts []*core.ArtifactRef
}
// planWorkflow 让规划子代理写出步骤,校验通过后必须经用户确认才执行。
func planWorkflow(rc RunCtx, goal string, runner WorkflowRunner) (workflowOutcome, error) {
sp, ok := rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
})
if !ok {
return workflowOutcome{}, fmt.Errorf("workflow: 当前运行不能规划")
}
res, err := sp.SpawnAgent(workflowPlannerID, "规划", "把下面的目标规划成 WorkflowDef。只输出 JSON,不要完成目标本身。\n目标:"+goal, "")
if err != nil {
return workflowOutcome{}, err
}
text := res.Text
if text == "" {
text = res.Summary
}
def, err := parseWorkflowDef(text)
if err != nil {
return workflowOutcome{}, err
}
known := map[string]bool{}
for _, tool := range rc.Tools() {
known[tool.Name] = true
}
if err := workflow.Validate(def, workflow.DynamicLimits(), func(name string) bool { return known[name] }); err != nil {
return workflowOutcome{}, err
}
resp, err := rc.AskHITL(core.HITLCard{
Question: planPreview(def), Kind: core.HITLKindConfirm, AllowFreeInput: false,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按这些步骤做", Impact: "会调用工具并可能写入工件"},
{ID: "no", Label: "取消", Description: "不执行", Impact: "不会改动任何东西"},
},
})
if err != nil {
return workflowOutcome{}, err
}
if resp.ChoiceID != "yes" {
return workflowOutcome{Summary: "未确认,未执行"}, nil
}
return runner.run(rc, workflow.Launch{Def: def})
}
func parseWorkflowDef(raw string) (core.WorkflowDef, error) {
s := stripFence(raw)
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end < start {
return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析")
}
var def core.WorkflowDef
if err := json.Unmarshal([]byte(s[start:end+1]), &def); err != nil {
return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析")
}
if strings.TrimSpace(def.ID) == "" {
def.ID = "dynamic"
}
if strings.TrimSpace(def.Title) == "" {
def.Title = "临时工作流"
}
return def, nil
}
func planPreview(def core.WorkflowDef) string {
var b strings.Builder
fmt.Fprintf(&b, "按这个计划执行「%s」?\n", def.Title)
for i, step := range def.Steps {
fmt.Fprintf(&b, "%d. %s(%s)\n", i+1, step.Title, step.Kind)
}
return strings.TrimRight(b.String(), "\n")
}
func (w WorkflowRunner) Execute(rc RunCtx) error {
src, ok := rc.(workflowSource)
if !ok {
return fmt.Errorf("workflow: 当前运行没有工作流定义")
}
launch, ok := src.Workflow()
if !ok {
return fmt.Errorf("workflow: 当前运行没有工作流定义")
}
res, err := w.run(rc, launch)
if len(res.Parts) > 0 {
_ = rc.SetAssistantParts(res.Parts)
}
return err
}
func (w WorkflowRunner) run(rc RunCtx, launch workflow.Launch) (workflowOutcome, error) {
def := launch.Def
vars := workflow.Vars{
Input: currentUserText(rc),
Slots: map[string]string{},
Steps: map[string]string{},
}
for k, v := range launch.Slots {
vars.Slots[k] = v
}
for _, missing := range launch.Missing {
if rc.Context().Err() != nil {
return workflowOutcome{}, rc.Context().Err()
}
resp, err := rc.AskHITL(core.HITLCard{
Question: missing.Question, Kind: core.HITLKindInput, AllowFreeInput: true,
})
if err != nil {
return workflowOutcome{}, err
}
answer := strings.TrimSpace(resp.FreeText)
if answer == "" {
answer = resp.ChoiceID
}
vars.Slots[missing.Key] = answer
}
plan := core.PlanSnapshot{Title: def.Title, Steps: make([]core.PlanStep, len(def.Steps))}
for i, step := range def.Steps {
plan.Steps[i] = core.PlanStep{ID: step.ID, Text: step.Title, Status: "pending"}
}
if err := rc.UpdatePlan(plan); err != nil {
return workflowOutcome{}, err
}
wholeLeft := def.MaxRetries
if wholeLeft <= 0 || wholeLeft > workflow.MaxWorkflowRetries {
wholeLeft = workflow.MaxWorkflowRetries
}
var drops []string
var arts []*core.ArtifactRef
var prev string
statuses := make([]string, len(def.Steps))
for i := range def.Steps {
if rc.Context().Err() != nil {
return outcome(def, statuses, arts, drops, ""), rc.Context().Err()
}
step := def.Steps[i]
plan.Steps[i].Status = "in_progress"
if err := rc.UpdatePlan(plan); err != nil {
return workflowOutcome{}, err
}
rendered, err := workflow.RenderStep(step, vars)
if err != nil {
return outcome(def, statuses, arts, drops, err.Error()), err
}
output, stepArts, reason, err := w.runStepWithCritic(rc, def, rendered, vars, prev, launch.Scripts, &wholeLeft, &drops)
arts = append(arts, stepArts...)
if err != nil {
statuses[i] = "failed"
plan.Steps[i].Status = "skipped"
_ = rc.UpdatePlan(plan)
res := outcome(def, statuses, arts, drops, reason)
return res, err
}
vars.Steps[step.ID] = output
prev = output
statuses[i] = "done"
plan.Steps[i].Status = "done"
if err := rc.UpdatePlan(plan); err != nil {
return workflowOutcome{}, err
}
}
res := outcome(def, statuses, arts, drops, "")
return res, nil
}
func (w WorkflowRunner) runStepWithCritic(rc RunCtx, def core.WorkflowDef, step core.WorkflowStep, vars workflow.Vars, prev string, scripts map[string]string, wholeLeft *int, drops *[]string) (string, []*core.ArtifactRef, string, error) {
stepRetries := 1
if step.Critic != nil && step.Critic.MaxRetries > 0 {
stepRetries = step.Critic.MaxRetries
}
if stepRetries > workflow.MaxStepRetries {
stepRetries = workflow.MaxStepRetries
}
used := 0
note := ""
var arts []*core.ArtifactRef
for {
current := step
if note != "" && current.Prompt != "" {
current.Prompt = current.Prompt + "\n\n未通过验收:" + note
} else if note != "" {
current.Prompt = "未通过验收:" + note
}
output, got, err := w.runWorkflowStep(rc, current, vars, prev, scripts)
arts = append(arts, got...)
if err != nil {
return output, arts, err.Error(), err
}
if step.Critic == nil || strings.TrimSpace(step.Critic.Criteria) == "" {
return output, arts, "", nil
}
verdict := w.judgeStep(rc, def.Title, step.Critic.Criteria, output)
if verdict.Passed {
return output, arts, "", nil
}
why := verdict.Reason
if verdict.NextAction != "" {
why += "。下一步:" + verdict.NextAction
}
*drops = append(*drops, step.ID+":"+why)
if used >= stepRetries || *wholeLeft <= 0 {
return output, arts, "步骤 " + step.Title + " 未通过验收:" + why, fmt.Errorf("workflow: 步骤 %s 未通过验收", step.ID)
}
used++
*wholeLeft--
note = why
}
}
func (w WorkflowRunner) runWorkflowStep(rc RunCtx, step core.WorkflowStep, vars workflow.Vars, prev string, scripts map[string]string) (string, []*core.ArtifactRef, error) {
switch step.Kind {
case "prompt":
return runPromptStep(rc, step)
case "tool":
res := rc.ExecuteTool(core.ToolCallRequest{Name: step.Tool, Arguments: step.Args})
if res.Status == "error" {
return res.Output, nil, fmt.Errorf("workflow: 工具 %s 失败:%s", step.Tool, res.Output)
}
var arts []*core.ArtifactRef
if res.Artifact != nil {
arts = append(arts, &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind})
}
return res.Output, arts, nil
case "hitl":
card := core.HITLCard{Question: step.Title, Kind: core.HITLKindInput, AllowFreeInput: true}
if step.Hitl != nil {
card.Question = step.Hitl.Question
card.Kind = step.Hitl.Kind
card.Choices = step.Hitl.Choices
card.AllowFreeInput = step.Hitl.AllowFreeInput
card.FreeInputHint = step.Hitl.FreeInputHint
}
resp, err := rc.AskHITL(card)
if err != nil {
return "", nil, err
}
if strings.TrimSpace(resp.FreeText) != "" {
return strings.TrimSpace(resp.FreeText), nil, nil
}
return resp.ChoiceID, nil, nil
case "artifact":
content := step.Prompt
if strings.TrimSpace(content) == "" {
content = prev
}
name := step.Artifact
if name == "" {
name = step.ID + ".md"
}
art, err := rc.WriteArtifact(name, "document", "text/markdown", []byte(content))
if err != nil {
return "", nil, err
}
return string(art.ID), []*core.ArtifactRef{{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}, nil
case "card":
card := core.ActivityCard{Title: step.Title, Kind: core.CardKindResult}
if step.Card != nil {
card.Kind = step.Card.Kind
card.Title = step.Card.Title
card.Tone = step.Card.Tone
card.Body = step.Card.Body
card.Actions = step.Card.Actions
}
created, err := rc.CreateCard(card)
if err != nil {
return "", nil, err
}
return string(created.ID), nil, nil
case "script":
return executeScriptStep(rc, w, step, scripts)
default:
return "", nil, fmt.Errorf("workflow: 未知步骤类型 %s", step.Kind)
}
}
func runPromptStep(rc RunCtx, step core.WorkflowStep) (string, []*core.ArtifactRef, error) {
agent := step.Agent
if strings.TrimSpace(agent) == "" {
agent = "general"
}
var res core.AgentResultPayload
var err error
if sp, ok := rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
}); ok {
res, err = sp.SpawnAgent(subagentProfileID(agent), step.Title, step.Prompt, "")
} else {
res, err = rc.SpawnSubagent(step.Title, step.Prompt, "")
}
if err != nil {
return "", nil, err
}
text := res.Text
if text == "" {
text = res.Summary
}
return text, artifactsIn(rc, text), nil
}
func artifactsIn(rc RunCtx, text string) []*core.ArtifactRef {
var out []*core.ArtifactRef
seen := map[string]bool{}
for _, id := range artIDRe.FindAllString(text, -1) {
if seen[id] {
continue
}
art, _, ok := rc.Artifact(id)
if !ok {
continue
}
seen[id] = true
out = append(out, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
}
return out
}
func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) verifyVerdict {
stream := w.JudgeStream
model := "verify"
if stream == nil {
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
if route.Model == "" || route.Base == "" {
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"}
}
model = route.Model
client := openLLMClient(route.Base, route.Key)
stream = client.Stream
}
prompt := fmt.Sprintf("你是验收员。目标:%s\n验收标准:%s\n这一步的产出:\n%s\n标准里要求出现的句子,产出里必须逐字包含,否则 passed 必须为 false,nextAction 写出要补上的原句。只输出一个 JSON 对象,字段为 passed、reason、nextAction。", goal, criteria, output)
var b strings.Builder
for ev := range stream(rc.Context(), model, []agentllm.Message{{Role: agentllm.RoleUser, Content: prompt}}, nil, agentllm.StreamOptions{}) {
switch ev.Kind {
case agentllm.KindTextDelta:
b.WriteString(ev.Text)
case agentllm.KindError:
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"}
case agentllm.KindMessageEnd:
if b.Len() == 0 {
b.WriteString(ev.Message.Content)
}
}
}
verdict, err := parseVerdict(b.String())
if err != nil {
return verifyVerdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"}
}
return verdict
}
func outcome(def core.WorkflowDef, statuses []string, arts []*core.ArtifactRef, drops []string, stopped string) workflowOutcome {
var b strings.Builder
title := def.Title
if title == "" {
title = def.ID
}
if stopped != "" {
fmt.Fprintf(&b, "工作流「%s」停止:%s\n", title, stopped)
} else {
fmt.Fprintf(&b, "工作流「%s」完成。\n", title)
}
for i, step := range def.Steps {
state := "pending"
if i < len(statuses) && statuses[i] != "" {
state = statuses[i]
}
fmt.Fprintf(&b, "- %s:%s\n", step.Title, state)
}
if len(arts) > 0 {
b.WriteString("工件:")
for i, art := range arts {
if i > 0 {
b.WriteString("、")
}
b.WriteString(string(art.ArtifactID))
}
b.WriteString("\n")
}
if len(drops) > 0 {
b.WriteString("已丢弃:")
b.WriteString(strings.Join(drops, ";"))
b.WriteString("\n")
}
parts := []core.Part{{Type: core.PartText, Text: strings.TrimRight(b.String(), "\n")}}
for _, art := range arts {
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: art})
}
return workflowOutcome{Summary: parts[0].Text, Parts: parts, Artifacts: arts}
}
+170
View File
@@ -0,0 +1,170 @@
package runtime
import (
"context"
"fmt"
"strings"
"time"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
// workflow_compiled.go — 工作流执行器(Q3 WP2):WorkflowDef 一律经
// internal/wfcompile 编译为 squad 图执行(legacy 逐步循环 + SpawnSubagent
// 压缩路径已退役)。P4 语义由编译入口与宿主处理器持有:步数上限、动态
// 工作流禁 script 且必须确认、模型脚本必须确认、runtime.workflowScripts
// 默认关、critic 每步 ≤3 整条 ≤6、脚本回调限额、DeniedTools。
const workflowPlannerID = "agent.sub.workflow-plan"
// workflowSource 由 runmgr 的 runCtx 实现:run 启动时拷贝的工作流定义。
type workflowSource interface {
Workflow() (workflow.Launch, bool)
}
// CompiledWorkflowRunner 以编译图执行启动时绑定的工作流(意图/演示入口)。
type CompiledWorkflowRunner struct {
// JudgeStream 注入验收模型;nil 时按轻量模型配置构造,仍缺则验证器不可用。
JudgeStream agentllm.StreamFunc
// ScriptTimeout 覆盖脚本墙钟超时(0 = 600s;1800s 上限由宿主持有)。
ScriptTimeout time.Duration
}
func (CompiledWorkflowRunner) Name() string { return "workflow" }
func (w CompiledWorkflowRunner) Execute(rc RunCtx) error {
src, ok := rc.(workflowSource)
if !ok {
return fmt.Errorf("workflow: 当前运行没有工作流定义")
}
launch, ok := src.Workflow()
if !ok {
return fmt.Errorf("workflow: 当前运行没有工作流定义")
}
out, err := w.runLaunch(rc, launch, wfcompile.RunOptions{})
if len(out.Parts) > 0 {
_ = rc.SetAssistantParts(out.Parts)
}
return err
}
// runLaunch 装配编译图执行依赖并跑一次工作流(三个 run_workflow 分支与
// 意图入口共用)。
func (w CompiledWorkflowRunner) runLaunch(rc RunCtx, launch workflow.Launch, opts wfcompile.RunOptions) (wfcompile.Outcome, error) {
deps := wfcompile.Deps{
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
Backends: squadadapter.BuildBackends(rc),
DefaultBackend: "agent-native",
JudgeStream: w.JudgeStream,
ScriptTimeout: w.ScriptTimeout,
}
if deps.JudgeStream == nil {
deps.JudgeStream = judgeStreamFor(rc)
}
return wfcompile.RunCompiled(workflowHost{rc: rc}, deps, launch, workflow.Vars{
Input: currentUserText(rc),
Slots: map[string]string{},
Steps: map[string]string{},
}, opts)
}
// judgeStreamFor 按轻量模型配置构造验收流(legacy judgeStep 回退路径同款:
// 模型名取路由结果;未配置 = nil,wfcompile 按「验证器不可用」fail-closed)。
func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
if route.Model == "" || route.Base == "" {
return nil
}
client := openLLMClient(route.Base, route.Key)
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)
}
}
// planCompiledWorkflow 规划分支(run_workflow goal=):规划子代理产出
// WorkflowDef → 动态上限编译 → 执行前强制确认(legacy planWorkflow 同款流)。
func planCompiledWorkflow(rc RunCtx, goal string, runner CompiledWorkflowRunner) (wfcompile.Outcome, error) {
sp, ok := rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
})
if !ok {
return wfcompile.Outcome{}, fmt.Errorf("workflow: 当前运行不能规划")
}
res, err := sp.SpawnAgent(workflowPlannerID, "规划", "把下面的目标规划成 WorkflowDef。只输出 JSON,不要完成目标本身。\n目标:"+goal, "")
if err != nil {
return wfcompile.Outcome{}, err
}
text := res.Text
if text == "" {
text = res.Summary
}
def, err := wfcompile.ParseWorkflowDef(text)
if err != nil {
return wfcompile.Outcome{}, err
}
return runner.runLaunch(rc, workflow.Launch{Def: def}, wfcompile.RunOptions{
DynamicLimits: true, RequireConfirm: true,
})
}
// registeredWorkflowLaunch 组装注册工作流的启动副本(脚本正文取装载快照,
// legacy 同款:插件后续变更不影响已启动运行)。
func registeredWorkflowLaunch(rc RunCtx, def core.WorkflowDef, slots map[string]string) workflow.Launch {
launch := workflow.Launch{Def: def, Slots: slots, Scripts: map[string]string{}}
lookup, ok := rc.(interface {
WorkflowScript(id, path string) (string, bool)
})
if !ok {
return launch
}
for _, step := range def.Steps {
if step.Kind != "script" || strings.TrimSpace(step.Script) == "" {
continue
}
if body, found := lookup.WorkflowScript(def.ID, step.Script); found {
launch.Scripts[step.Script] = body
}
}
return launch
}
// modelScriptLaunch 把模型提供的脚本正文包成单步工作流(script 分支;
// 确认与开关语义由 wfcompile 宿主处理器持有)。
func modelScriptLaunch(body string) workflow.Launch {
def := core.WorkflowDef{ID: "model.script", Title: "模型脚本", Steps: []core.WorkflowStep{
{ID: "run", Title: "跑脚本", Kind: "script", Script: "model.py"},
}}
return workflow.Launch{Def: def, Scripts: map[string]string{"model.py": body}}
}
// workflowHost 把 RunCtx 适配到 wfcompile.Host(ConversationSandbox 是
// 运行侧可选能力,缺失时回退空串由宿主处理器 fail-closed)。
type workflowHost struct{ rc RunCtx }
func (h workflowHost) Context() context.Context { return h.rc.Context() }
func (h workflowHost) Run() core.Run { return h.rc.Run() }
func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() }
func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) }
func (h workflowHost) AskHITL(c core.HITLCard) (core.HITLResponse, error) { return h.rc.AskHITL(c) }
func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) }
func (h workflowHost) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
return h.rc.WriteArtifact(name, kind, mime, content)
}
func (h workflowHost) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
return h.rc.ExecuteTool(call)
}
func (h workflowHost) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
return h.rc.CreateCard(card)
}
func (h workflowHost) Tools() []core.ToolDesc { return h.rc.Tools() }
func (h workflowHost) ConversationSandbox() string {
if sb, ok := h.rc.(interface{ ConversationSandbox() string }); ok {
return sb.ConversationSandbox()
}
return ""
}
@@ -10,10 +10,16 @@ import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
// workflow_compiled_test.go — Q3 WP2 行为一致性(P4):run_workflow 三分支
// 改投编译图后的 runtime 级验证——注册工作流查找与错误面、规划分支确认门
// 与动态上限、模型脚本开关、缺失槽位 HITL 收齐、验收模型路由。
// (编译/宿主语义的细粒度断言在 internal/wfcompile 测试内。)
type wfFake struct {
ctx context.Context
launch workflow.Launch
@@ -158,104 +164,9 @@ func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) {
return def, ok
}
func TestWorkflowRunsFiveKindsAndAsksMissingSlot(t *testing.T) {
f := &wfFake{hitl: []core.HITLResponse{{FreeText: "核能"}, {ChoiceID: "short"}}}
f.launch = workflow.Launch{
Missing: []workflow.SlotQuestion{{Key: "topic", Question: "主题是什么?"}},
Def: core.WorkflowDef{ID: "demo.brief", Title: "简报", Steps: []core.WorkflowStep{
{ID: "len", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{Question: "篇幅?", Kind: core.HITLKindChoice, Choices: []core.HITLChoice{{ID: "short", Label: "短"}}}},
{ID: "draft", Title: "起草", Kind: "prompt", Agent: "research", Prompt: "写 {{slots.topic}}"},
{ID: "now", Title: "时间", Kind: "tool", Tool: "time_now", Args: []byte(`{"q":"{{slots.topic}}"}`)},
{ID: "save", Title: "存稿", Kind: "artifact", Artifact: "brief.md", Prompt: "{{steps.draft.output}}"},
{ID: "note", Title: "通知", Kind: "card", Card: &core.ActivityCardTemplate{Kind: core.CardKindResult, Title: "好了"}},
}},
}
if err := (WorkflowRunner{}).Execute(f); err != nil {
t.Fatal(err)
}
if f.hitlQ[0] != "主题是什么?" || f.hitlQ[1] != "篇幅?" {
t.Fatalf("questions %v", f.hitlQ)
}
if len(f.prompts) != 1 || !strings.Contains(f.prompts[0], "agent.sub.research|写 核能") {
t.Fatalf("prompt %v", f.prompts)
}
if len(f.tools) != 1 || string(f.tools[0].Arguments) == "" || !strings.Contains(string(f.tools[0].Arguments), "核能") {
t.Fatalf("tool %#v", f.tools)
}
if len(f.writes) != 1 || f.writes[0] != "brief.md=正文 art_0123456789ab" {
t.Fatalf("writes %v", f.writes)
}
if len(f.cards) != 1 || f.cards[0].Title != "好了" {
t.Fatalf("cards %+v", f.cards)
}
var sawProgress, sawDone bool
for _, p := range f.plans {
for _, s := range p.Steps {
if s.ID == "draft" && s.Status == "in_progress" {
sawProgress = true
}
if s.ID == "note" && s.Status == "done" {
sawDone = true
}
}
}
if !sawProgress || !sawDone {
t.Fatal("plan did not move from in_progress to done")
}
text := f.assistant[0].Text
if !strings.Contains(text, "art_0123456789ab") || !strings.Contains(text, "art_aaaaaaaaaaaa") {
t.Fatalf("summary %s", text)
}
}
func TestCriticRetriesThenStops(t *testing.T) {
f := &wfFake{}
f.launch = workflow.Launch{Def: core.WorkflowDef{
ID: "a", Title: "验收", MaxRetries: 6,
Steps: []core.WorkflowStep{{
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
Critic: &core.StepCritic{Criteria: "必须点明主题", MaxRetries: 1},
}},
}}
runner := WorkflowRunner{JudgeStream: passedStream(
`{"passed":false,"reason":"没点明主题","nextAction":"补上主题"}`,
`{"passed":true,"reason":"可以","nextAction":""}`,
)}
if err := runner.Execute(f); err != nil {
t.Fatal(err)
}
if len(f.prompts) != 2 || !strings.Contains(f.prompts[1], "没点明主题") || !strings.Contains(f.prompts[1], "补上主题") {
t.Fatalf("prompts %v", f.prompts)
}
if !strings.Contains(f.assistant[0].Text, "已丢弃") || !strings.Contains(f.assistant[0].Text, "没点明主题") {
t.Fatalf("summary %s", f.assistant[0].Text)
}
f2 := &wfFake{}
f2.launch = f.launch
bad := WorkflowRunner{JudgeStream: passedStream("不是 json", "不是 json")}
err := bad.Execute(f2)
if err == nil || len(f2.prompts) != 2 {
t.Fatalf("err %v prompts %d", err, len(f2.prompts))
}
if !strings.Contains(f2.assistant[0].Text, "无法解析") {
t.Fatalf("summary %s", f2.assistant[0].Text)
}
f3 := &wfFake{}
f3.launch = workflow.Launch{Def: core.WorkflowDef{
ID: "a", Title: "验收", MaxRetries: 1,
Steps: []core.WorkflowStep{{
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
Critic: &core.StepCritic{Criteria: "要过", MaxRetries: 3},
}},
}}
fail := `{"passed":false,"reason":"不行","nextAction":"重写"}`
stop := WorkflowRunner{JudgeStream: passedStream(fail, fail, fail)}
if err := stop.Execute(f3); err == nil || len(f3.prompts) != 2 {
t.Fatalf("whole cap err %v attempts %d", err, len(f3.prompts))
}
}
// ---- run_workflow 注册分支:查找与错误面(legacy 同款文案) ----
func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
f := &wfFake{hitl: []core.HITLResponse{{ChoiceID: "yes"}}}
@@ -277,7 +188,82 @@ func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
}
}
func TestJudgeUsesConfiguredModel(t *testing.T) {
// ---- 规划分支:确认门(engine confirmation)+ 动态上限 fail-closed ----
func TestPlannedWorkflowNeedsConfirm(t *testing.T) {
plan := `{"id":"plan.brief","title":"简报","steps":[{"id":"ask","title":"确认","kind":"hitl","hitl":{"question":"可以吗?","kind":"confirm","allowFreeInput":false}}]}`
f := &wfFake{
spawnText: plan,
hitl: []core.HITLResponse{{ChoiceID: "approve"}, {ChoiceID: "ok"}},
}
st := &directRun{rc: f}
out := st.runRegisteredWorkflow(`{"goal":"写一份简报"}`)
if f.spawnIDs[0] != "agent.sub.workflow-plan" || !strings.Contains(f.hitlQ[0], "确认") {
t.Fatalf("spawn %v questions %v", f.spawnIDs, f.hitlQ)
}
if !strings.Contains(out, "完成") || f.hitlQ[1] != "可以吗?" {
t.Fatalf("out %s questions %v", out, f.hitlQ)
}
// 计划确认被拒 → legacy 语义「未确认,未执行」。
f2 := &wfFake{spawnText: plan, hitl: []core.HITLResponse{{ChoiceID: "abort"}}}
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"goal":"写一份简报"}`)
if out != "未确认,未执行" || len(f2.hitlQ) != 1 {
t.Fatalf("out %s questions %v", out, f2.hitlQ)
}
// 动态上限:9 步 > 8,编译入口拒绝,不进任何 HITL。
var steps []string
for i := 0; i < 9; i++ {
steps = append(steps, fmt.Sprintf(`{"id":"s%d","title":"步","kind":"hitl"}`, i))
}
f3 := &wfFake{spawnText: `{"id":"too.big","title":"太多","steps":[` + strings.Join(steps, ",") + `]}`}
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"goal":"做很多事"}`)
if !strings.Contains(out, "超出上限") || len(f3.hitlQ) != 0 {
t.Fatalf("out %s questions %v", out, f3.hitlQ)
}
}
// ---- 脚本分支:runtime.workflowScripts 开关(默认关) ----
func TestScriptBranchRespectsSwitch(t *testing.T) {
st := &directRun{rc: &wfFake{sandbox: t.TempDir()}}
out := st.runScriptWorkflow("import agentwf as wf\nwf.step('run','跑','done')\n")
if !strings.Contains(out, "工作流脚本未开启") {
t.Fatalf("开关默认关应失败:%s", out)
}
}
// ---- 缺失槽位:执行前 HITL 收齐并参与编译期渲染 ----
func TestCompiledRunnerAsksMissingSlot(t *testing.T) {
f := &wfFake{hitl: []core.HITLResponse{{FreeText: "核能"}, {ChoiceID: "short"}}}
f.launch = workflow.Launch{
Missing: []workflow.SlotQuestion{{Key: "topic", Question: "主题是什么?"}},
Def: core.WorkflowDef{ID: "slots", Title: "槽位", Steps: []core.WorkflowStep{
{ID: "len", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{Question: "篇幅?", Kind: core.HITLKindChoice, Choices: []core.HITLChoice{{ID: "short", Label: "短"}}}},
{ID: "save", Title: "存稿", Kind: "artifact", Artifact: "brief.md", Prompt: "主题:{{slots.topic}},篇幅:{{steps.len.output}}"},
}},
}
if err := (CompiledWorkflowRunner{}).Execute(f); err != nil {
t.Fatal(err)
}
if f.hitlQ[0] != "主题是什么?" || f.hitlQ[1] != "篇幅?" {
t.Fatalf("questions %v", f.hitlQ)
}
if len(f.writes) != 1 || !strings.HasPrefix(f.writes[0], "brief.md=") ||
!strings.Contains(f.writes[0], "主题:核能") || !strings.Contains(f.writes[0], "short") {
t.Fatalf("writes = %v", f.writes)
}
text := f.assistant[0].Text
if !strings.Contains(text, "工作流「槽位」完成。") {
t.Fatalf("summary %s", text)
}
}
// ---- 验收模型路由:轻量模型配置 → JudgeStream;未配置 → nil(fail-closed) ----
func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
@@ -288,60 +274,22 @@ func TestJudgeUsesConfiguredModel(t *testing.T) {
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
t.Cleanup(srv.Close)
f := &wfFake{
settings: core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
"baseUrl": srv.URL, "apiKey": "test-key", "model": "MiniMax-test",
}}},
launch: workflow.Launch{Def: core.WorkflowDef{
ID: "c", Title: "验收",
Steps: []core.WorkflowStep{{
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
Critic: &core.StepCritic{Criteria: "要过", MaxRetries: 1},
}},
}},
f := &wfFake{settings: core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
"baseUrl": srv.URL, "apiKey": "test-key", "model": "MiniMax-test",
}}}}
stream := judgeStreamFor(f)
if stream == nil {
t.Fatal("已配置轻量模型时必须给出验收流")
}
if err := (WorkflowRunner{}).Execute(f); err != nil {
t.Fatal(err)
for ev := range stream(context.Background(), "verify", []agentllm.Message{{Role: agentllm.RoleUser, Content: "x"}}, nil, agentllm.StreamOptions{}) {
if ev.Kind == agentllm.KindError {
t.Fatal(ev.Text)
}
}
if got != "MiniMax-test" {
t.Fatalf("model %q", got)
}
}
func TestPlannedWorkflowNeedsConfirm(t *testing.T) {
plan := `{"id":"plan.brief","title":"简报","steps":[{"id":"ask","title":"确认","kind":"hitl","hitl":{"question":"可以吗?","kind":"confirm","allowFreeInput":false}}]}`
f := &wfFake{
spawnText: plan,
hitl: []core.HITLResponse{{ChoiceID: "yes"}, {ChoiceID: "ok"}},
}
st := &directRun{rc: f}
out := st.runRegisteredWorkflow(`{"goal":"写一份简报"}`)
if f.spawnIDs[0] != "agent.sub.workflow-plan" || !strings.Contains(f.hitlQ[0], "按这个计划执行") {
t.Fatalf("spawn %v questions %v", f.spawnIDs, f.hitlQ)
}
if !strings.Contains(out, "完成") || f.hitlQ[1] != "可以吗?" {
t.Fatalf("out %s questions %v", out, f.hitlQ)
}
f2 := &wfFake{spawnText: plan, hitl: []core.HITLResponse{{ChoiceID: "no"}}}
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"goal":"写一份简报"}`)
if out != "未确认,未执行" || len(f2.hitlQ) != 1 {
t.Fatalf("out %s questions %v", out, f2.hitlQ)
}
var steps []string
for i := 0; i < 9; i++ {
steps = append(steps, fmt.Sprintf(`{"id":"s%d","title":"步","kind":"hitl"}`, i))
}
f3 := &wfFake{spawnText: `{"id":"too.big","title":"太多","steps":[` + strings.Join(steps, ",") + `]}`}
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"goal":"做很多事"}`)
if !strings.Contains(out, "超出上限") || len(f3.hitlQ) != 0 {
t.Fatalf("out %s questions %v", out, f3.hitlQ)
}
f4 := &wfFake{spawnText: `{"id":"bad.script","title":"脚本","steps":[{"id":"run","title":"跑","kind":"script","script":"a.py"}]}`}
out = (&directRun{rc: f4}).runRegisteredWorkflow(`{"goal":"跑脚本"}`)
if !strings.Contains(out, "script") || len(f4.hitlQ) != 0 {
t.Fatalf("out %s questions %v", out, f4.hitlQ)
if judgeStreamFor(&wfFake{}) != nil {
t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)")
}
}
-297
View File
@@ -1,297 +0,0 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"agent-native/runtime/codeorch"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
const (
scriptDefaultTimeout = 600 * time.Second
scriptMaxTimeout = 1800 * time.Second
scriptMaxCalls = 50
scriptMaxLine = 1 << 20
)
func scriptsEnabled(rc RunCtx) bool {
return rc.Settings().GetBool("runtime.workflowScripts", false)
}
func scriptSandbox(rc RunCtx) (string, error) {
sandbox := strings.TrimSpace(conversationSandbox(rc))
if sandbox == "" {
return "", fmt.Errorf("workflow: 没有会话沙箱")
}
return sandbox, nil
}
func executeScriptStep(rc RunCtx, runner WorkflowRunner, step core.WorkflowStep, scripts map[string]string) (string, []*core.ArtifactRef, error) {
body := scripts[step.Script]
if strings.TrimSpace(body) == "" {
var err error
body, err = scriptBody(rc, step.Script)
if err != nil {
return "", nil, err
}
}
sandbox, err := scriptSandbox(rc)
if err != nil {
return "", nil, err
}
path, err := writeScriptFile(sandbox, "step-"+step.ID, body)
if err != nil {
return "", nil, err
}
return runScriptFile(rc, runner, path)
}
func scriptBody(rc RunCtx, path string) (string, error) {
src, ok := rc.(interface {
Workflow() (workflow.Launch, bool)
})
if !ok {
return "", fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path)
}
launch, ok := src.Workflow()
if !ok {
return "", fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path)
}
body, ok := launch.Scripts[path]
if !ok || strings.TrimSpace(body) == "" {
return "", fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path)
}
return body, nil
}
func registeredWorkflowLaunch(rc RunCtx, def core.WorkflowDef, slots map[string]string) workflow.Launch {
launch := workflow.Launch{Def: def, Slots: slots, Scripts: map[string]string{}}
lookup, ok := rc.(interface {
WorkflowScript(id, path string) (string, bool)
})
if !ok {
return launch
}
for _, step := range def.Steps {
if step.Kind != "script" || strings.TrimSpace(step.Script) == "" {
continue
}
if body, found := lookup.WorkflowScript(def.ID, step.Script); found {
launch.Scripts[step.Script] = body
}
}
return launch
}
func runModelScript(rc RunCtx, runner WorkflowRunner, body string) (string, []*core.ArtifactRef, error) {
if !scriptsEnabled(rc) {
return "", nil, fmt.Errorf("workflow: 工作流脚本未开启")
}
preview := body
if len(preview) > 4000 {
preview = preview[:4000] + "\n…(脚本超长,已截断展示)"
}
resp, err := rc.AskHITL(core.HITLCard{
Question: "运行这段脚本?\n" + preview, Kind: core.HITLKindConfirm, AllowFreeInput: false,
Choices: []core.HITLChoice{
{ID: "yes", Label: "运行", Description: "在会话沙箱里执行", Impact: "会调用工具并可能写入工件"},
{ID: "no", Label: "取消", Description: "不执行", Impact: "不会改动任何东西"},
},
})
if err != nil {
return "", nil, err
}
if resp.ChoiceID != "yes" {
return "未确认,未执行", nil, nil
}
sandbox, err := scriptSandbox(rc)
if err != nil {
return "", nil, err
}
path, err := writeScriptFile(sandbox, "model", body)
if err != nil {
return "", nil, err
}
return runScriptFile(rc, runner, path)
}
func writeScriptFile(sandbox, prefix, body string) (string, error) {
dir := filepath.Join(sandbox, ".agent")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("wf-%s-%d.py", prefix, time.Now().UnixNano()))
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return "", err
}
return path, nil
}
func runScriptFile(rc RunCtx, runner WorkflowRunner, path string) (string, []*core.ArtifactRef, error) {
if !scriptsEnabled(rc) {
return "", nil, fmt.Errorf("workflow: 工作流脚本未开启")
}
sandbox, err := scriptSandbox(rc)
if err != nil {
return "", nil, err
}
timeout := runner.ScriptTimeout
if timeout <= 0 {
timeout = scriptDefaultTimeout
}
parent := rc.Context()
if parent == nil {
parent = context.Background()
}
var arts []*core.ArtifactRef
out := codeorch.RunFile(parent, sandbox, path, codeorch.Limits{
Timeout: timeout,
MaxTimeout: scriptMaxTimeout,
MaxCalls: scriptMaxCalls,
MaxResultBytes: scriptMaxLine,
MaxModelChars: 16000,
FullDir: sandbox,
}, func(ctx context.Context, dir, scriptPath string) (*exec.Cmd, error) {
return tools.ScriptCommand(ctx, dir, scriptPath)
}, func(_ context.Context, call codeorch.Call) (string, error) {
output, got, err := dispatchScript(rc, call)
arts = append(arts, got...)
return output, err
})
return out.ModelText(), arts, out.Err
}
func dispatchScript(rc RunCtx, req codeorch.Call) (string, []*core.ArtifactRef, error) {
var choices []core.HITLChoice
if len(req.Choices) > 0 {
_ = json.Unmarshal(req.Choices, &choices)
}
switch req.Op {
case "tool.call":
if _, denied := workflow.DeniedTools[req.Name]; denied {
return "", nil, fmt.Errorf("不能调用 %s", req.Name)
}
res := rc.ExecuteTool(core.ToolCallRequest{Name: req.Name, Arguments: req.Args})
if res.Status == "error" {
return "", nil, fmt.Errorf("%s", res.Output)
}
var arts []*core.ArtifactRef
if res.Artifact != nil {
arts = append(arts, &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind})
}
arts = append(arts, artifactsIn(rc, res.Output)...)
return res.Output, arts, nil
case "hitl.ask":
card := core.HITLCard{Question: req.Question, Kind: core.HITLKindInput, AllowFreeInput: true}
if len(choices) > 0 {
card.Kind = core.HITLKindChoice
card.AllowFreeInput = false
card.Choices = choices
for i := range card.Choices {
if card.Choices[i].Impact == "" {
card.Choices[i].Impact = "会按这个选择继续"
}
}
}
resp, err := rc.AskHITL(card)
if err != nil {
return "", nil, err
}
if strings.TrimSpace(resp.FreeText) != "" {
return strings.TrimSpace(resp.FreeText), nil, nil
}
return resp.ChoiceID, nil, nil
case "agent.spawn":
agent := req.Agent
if strings.TrimSpace(agent) == "" {
agent = "general"
}
var res core.AgentResultPayload
var err error
if sp, ok := rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
}); ok {
res, err = sp.SpawnAgent(subagentProfileID(agent), req.Title, req.Task, "")
} else {
res, err = rc.SpawnSubagent(req.Title, req.Task, "")
}
if err != nil {
return "", nil, err
}
text := res.Text
if text == "" {
text = res.Summary
}
return text, artifactsIn(rc, text), nil
case "artifact.register":
name := req.Name
if name == "" {
name = "script.md"
}
art, err := rc.WriteArtifact(name, "document", "text/markdown", []byte(req.Content))
if err != nil {
return "", nil, err
}
return string(art.ID), []*core.ArtifactRef{{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}, nil
case "card.create":
kind := core.CardKind(req.Kind)
if kind == "" {
kind = core.CardKindResult
}
title := req.Title
if title == "" {
title = req.Name
}
created, err := rc.CreateCard(core.ActivityCard{Title: title, Kind: kind})
if err != nil {
return "", nil, err
}
return string(created.ID), nil, nil
case "step":
applyScriptPlan(rc, req.StepID, req.Title, req.Status)
return req.Status, nil, nil
default:
return "", nil, fmt.Errorf("未知回调 %s", req.Op)
}
}
func applyScriptPlan(rc RunCtx, id, title, status string) {
switch status {
case "pending", "in_progress", "done", "skipped":
default:
status = "done"
}
snap := core.PlanSnapshot{}
if src, ok := rc.(interface {
Plan() (core.PlanSnapshot, bool)
}); ok {
if cur, found := src.Plan(); found {
snap = cur
snap.Steps = append([]core.PlanStep(nil), cur.Steps...)
}
}
found := false
for i := range snap.Steps {
if snap.Steps[i].ID == id {
snap.Steps[i].Status = status
if title != "" {
snap.Steps[i].Text = title
}
found = true
break
}
}
if !found {
snap.Steps = append(snap.Steps, core.PlanStep{ID: id, Text: title, Status: status})
}
_ = rc.UpdatePlan(snap)
}
-243
View File
@@ -1,243 +0,0 @@
package runtime
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
func scriptSettings() core.EffectiveSettings {
return core.EffectiveSettings{"runtime": map[string]any{"workflowScripts": true}}
}
func TestScriptStepRunsEachCallback(t *testing.T) {
body := `import agentwf as wf
print("tool", wf.tool("time_now", {}))
print("ask", wf.ask("继续?", [{"id":"yes","label":"好","impact":"继续"}]))
print("agent", wf.agent("research", "写一句"))
print("art", wf.artifact("note.md", "你好"))
print("card", wf.card("结果", "result"))
print("step", wf.step("s1", "一步", "done"))
print("file", wf.tool("make_file", {}))
`
f := &wfFake{
sandbox: t.TempDir(),
settings: scriptSettings(),
hitl: []core.HITLResponse{{ChoiceID: "yes"}},
launch: workflow.Launch{
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{
{ID: "run", Title: "跑", Kind: "script", Script: "workflows/scripts/demo.py"},
{ID: "save", Title: "留下", Kind: "artifact", Prompt: "{{steps.run.output}}"},
}},
Scripts: map[string]string{"workflows/scripts/demo.py": body},
},
}
if err := (WorkflowRunner{}).Execute(f); err != nil {
t.Fatal(err)
}
saved := f.writes[len(f.writes)-1]
for _, want := range []string{"tool 12:00", "ask yes", "agent 正文", "art art_aaaaaaaaaaaa", "card card_1", "file wrote"} {
if !strings.Contains(saved, want) {
t.Fatalf("missing %q in %s", want, saved)
}
}
if len(f.tools) != 2 || f.tools[0].Name != "time_now" || f.tools[1].Name != "make_file" {
t.Fatalf("tools %+v", f.tools)
}
if len(f.cards) != 1 || f.cards[0].Title != "结果" {
t.Fatalf("cards %+v", f.cards)
}
if len(f.spawnIDs) != 1 || f.spawnIDs[0] != "agent.sub.research" {
t.Fatalf("spawn %v", f.spawnIDs)
}
if f.writes[0] != "note.md=你好" {
t.Fatalf("writes %v", f.writes)
}
sawStep := false
var ids []string
for _, plan := range f.plans {
for _, step := range plan.Steps {
if step.ID == "s1" && step.Status == "done" {
sawStep = true
}
}
}
for _, part := range f.assistant {
if part.Artifact != nil {
ids = append(ids, string(part.Artifact.ArtifactID))
}
}
if !sawStep {
t.Fatalf("plans %+v", f.plans)
}
joined := strings.Join(ids, " ")
if !strings.Contains(joined, "art_aaaaaaaaaaaa") || !strings.Contains(joined, "art_bbbbbbbbbbbb") {
t.Fatalf("deliverables %v", ids)
}
}
func TestScriptRejectsDeniedTool(t *testing.T) {
dir := t.TempDir()
body := `import agentwf as wf
try:
wf.tool("run_python", {})
except Exception as exc:
open("ERR", "w").write(str(exc))
`
f := &wfFake{
sandbox: dir,
settings: scriptSettings(),
launch: workflow.Launch{
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
}}},
Scripts: map[string]string{"a.py": body},
},
}
if err := (WorkflowRunner{}).Execute(f); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "ERR"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "不能调用 run_python") || len(f.tools) != 0 {
t.Fatalf("err %s tools %+v", raw, f.tools)
}
}
func TestScriptStopsAfterCallCap(t *testing.T) {
body := `import agentwf as wf
for i in range(60):
wf.tool("time_now", {})
`
f := &wfFake{
sandbox: t.TempDir(),
settings: scriptSettings(),
launch: workflow.Launch{
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
}}},
Scripts: map[string]string{"a.py": body},
},
}
err := (WorkflowRunner{}).Execute(f)
if err == nil || !strings.Contains(err.Error(), "超过 50") || len(f.tools) != 50 {
t.Fatalf("err %v calls %d", err, len(f.tools))
}
}
func TestScriptCancelKillsProcess(t *testing.T) {
dir := t.TempDir()
body := `import os, time
open("PID", "w").write(str(os.getpid()))
time.sleep(30)
open("AFTER", "w").write("ran")
`
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &wfFake{
ctx: ctx,
sandbox: dir,
settings: scriptSettings(),
launch: workflow.Launch{
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
}}},
Scripts: map[string]string{"a.py": body},
},
}
done := make(chan error, 1)
go func() { done <- (WorkflowRunner{}).Execute(f) }()
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(filepath.Join(dir, "PID")); err == nil {
break
}
if time.Now().After(deadline) {
cancel()
t.Fatal("script did not start")
}
time.Sleep(20 * time.Millisecond)
}
raw, err := os.ReadFile(filepath.Join(dir, "PID"))
if err != nil {
t.Fatal(err)
}
cancel()
select {
case err = <-done:
if err == nil || !strings.Contains(err.Error(), "取消") {
t.Fatalf("%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("cancel did not stop the script")
}
if _, err := os.Stat("/proc/" + strings.TrimSpace(string(raw))); err == nil {
t.Fatal("script process still alive")
}
if _, err := os.Stat(filepath.Join(dir, "AFTER")); err == nil {
t.Fatal("script kept running after cancel")
}
}
func TestScriptLineLimit(t *testing.T) {
body := `import os
f = os.fdopen(3, "w", encoding="utf-8", buffering=1)
f.write("x" * (1024 * 1024 + 32) + "\n")
f.flush()
`
f := &wfFake{
sandbox: t.TempDir(),
settings: scriptSettings(),
launch: workflow.Launch{
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
ID: "run", Title: "跑", Kind: "script", Script: "a.py",
}}},
Scripts: map[string]string{"a.py": body},
},
}
err := (WorkflowRunner{}).Execute(f)
if err == nil || !strings.Contains(err.Error(), "1MB") {
t.Fatal(err)
}
}
func TestRegisteredWorkflowUsesItsOwnScriptCopy(t *testing.T) {
f := &wfFake{sandbox: t.TempDir(), settings: scriptSettings()}
res, err := (WorkflowRunner{}).run(f, workflow.Launch{
Def: core.WorkflowDef{ID: "demo.script", Title: "脚本", Steps: []core.WorkflowStep{{
ID: "run", Title: "跑", Kind: "script", Script: "workflows/scripts/demo.py",
}}},
Scripts: map[string]string{"workflows/scripts/demo.py": "print('from-launch')\n"},
})
if err != nil || !strings.Contains(res.Summary, "完成") {
t.Fatalf("err %v summary %s", err, res.Summary)
}
}
func TestModelScriptNeedsSwitchAndConfirm(t *testing.T) {
f := &wfFake{sandbox: t.TempDir()}
out := (&directRun{rc: f}).runRegisteredWorkflow(`{"script":"print('pong')"}`)
if !strings.Contains(out, "未开启") || len(f.hitlQ) != 0 {
t.Fatalf("%s questions %v", out, f.hitlQ)
}
f2 := &wfFake{sandbox: t.TempDir(), settings: scriptSettings(), hitl: []core.HITLResponse{{ChoiceID: "no"}}}
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"script":"print('pong')"}`)
if out != "未确认,未执行" || !strings.Contains(f2.hitlQ[0], "print('pong')") {
t.Fatalf("%s questions %v", out, f2.hitlQ)
}
f3 := &wfFake{sandbox: t.TempDir(), settings: scriptSettings(), hitl: []core.HITLResponse{{ChoiceID: "yes"}}}
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"script":"print('pong')"}`)
if !strings.Contains(out, "pong") {
t.Fatalf("%s", out)
}
}
+107
View File
@@ -0,0 +1,107 @@
package runtime
import (
"context"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// wp5_ab_test.go 是 N1 WP5 验收的 A/B 对照:门禁机制下沉后,
// 同一组脚本化模型输出的停止原因、提醒次数与工具拒绝必须与记录的
// 旧行为逐条一致。既有套件(GuardTwiceThenNotice / VerifierRejectsThenPasses /
// VerifierGarbageFailsClosed / TodoReminder 三测)在实现切换后原样通过,
// 是性格化等价的主网;本文件补齐两个此前无覆盖的验收角度。
// TestVerifyCapNoticeAfterThreeContinuations:验证器持续拒绝时,
// 续跑恰为 3 次(gates.MaxVerifyContinuations),随后以 verifyCapNotice 收尾,
// 理由逐次进入过程区。
func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("答案 v1。"),
textDeltaTurn("答案 v2。"),
textDeltaTurn("答案 v3。"),
textDeltaTurn("答案 v4。"),
}
var mainCalls int
base := scriptStream(turns, nil)
r := &LLMRunner{
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
mainCalls++
return base(ctx, model, msgs, tools, opts)
},
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
return passedStream(`{"passed":false,"reason":"来源未标注","nextAction":"补上出处"}`)(ctx, model, msgs, tools, opts)
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if mainCalls != 4 {
t.Fatalf("main calls = %d, want 4(3 次续跑 + 收尾)", mainCalls)
}
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)
}
// 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。
notes := 0
for _, p := range rc.assistant {
if p.Type == core.PartCheck && p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "来源未标注") {
notes++
}
}
if notes != 4 {
t.Fatalf("verify notes = %d, want 4(3 次续跑 + 1 次封顶); checks = %+v", notes, rc.assistant)
}
// 前三个被降级的回复进了过程区;最后一次回复与收尾 Notice 留在正式区。
demoted := 0
for _, p := range texts {
if p.Process && strings.HasPrefix(p.Text, "答案 v") {
demoted++
}
}
if demoted != 3 {
t.Fatalf("demoted answers = %d, want 3; parts = %+v", demoted, texts)
}
}
// TestGateReasonTextParityAfterSink:下沉后门禁分支理由逐字一致——
// verify 开启时,声称交付无工件的理由必须原样进入过程区。
func TestGateReasonTextParityAfterSink(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("文档已生成:甲.docx"),
textDeltaTurn("文档已保存:乙.docx"),
textDeltaTurn("文档已导出:丙.docx"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if !hasProcessNote(rc.assistant, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件") {
t.Fatalf("gate reason missing after sink: %+v", textParts(rc.assistant))
}
texts := textParts(rc.assistant)
last := texts[len(texts)-1]
if last.Process || !strings.Contains(last.Text, "本轮未实际生成文件") {
t.Fatalf("final notice = %+v", last)
}
// 伪造调用标记的分支理由也要逐字一致。
r2 := &LLMRunner{Stream: scriptStream([][]agentllm.Event{
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
}, nil)}
rc2 := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r2.Execute(rc2); err != nil {
t.Fatalf("Execute: %v", err)
}
if !hasProcessNote(rc2.assistant, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用") {
t.Fatalf("forged marker reason missing after sink: %+v", textParts(rc2.assistant))
}
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
agentcommand "agent-command"
)
// 命令审计按租户作用域(回归:GET /api/commands/audit 曾返回全租户审计行——
// ListAudits 无 owner 条件、command_audit 无 owner 列;多用户档下任何登录
// 用户可读他人命令 ID、入口、会话 ID 与参数摘要)。
// 经真实认证中间件(API key → owner)请求,断言每个租户只见本租户的行。
func TestCommandAuditOwnerScoping(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
aliceKey, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bobKey, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
// 两租户各产生一条命令审计(owner 列=v18 起的归属依据;Actor 仅为信封内容)。
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{
CommandID: "cmd.alice", Actor: "alice", State: agentcommand.StateCommitted,
}); err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("bob", agentcommand.AuditEnvelope{
CommandID: "cmd.bob", Actor: "bob", State: agentcommand.StateCommitted,
}); err != nil {
t.Fatal(err)
}
get := func(key string) []map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/commands/audit", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("audit query: %d %s", rec.Code, rec.Body.String())
}
var out struct {
Audits []map[string]any `json:"audits"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Audits
}
for key, owner := range map[string]string{aliceKey: "alice", bobKey: "bob"} {
rows := get(key)
if len(rows) != 1 {
t.Fatalf("owner %s: audit rows = %d, want 1(跨租户行不得返回;got=%v)", owner, len(rows), rows)
}
if rows[0]["actor"] != owner || rows[0]["commandId"] != "cmd."+owner {
t.Fatalf("owner %s: wrong row %v", owner, rows[0])
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ type pair struct {
type nopAudit struct{}
func (nopAudit) AppendAudit(agentcommand.AuditEnvelope) error { return nil }
func (nopAudit) AppendAudit(string, agentcommand.AuditEnvelope) error { return nil }
// 每个内置命令都必须在 command-pairs.json 里同时声明按钮入口与对话入口。
// 新命令漏登记则本测试失败,合入被挡住。
+154 -10
View File
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
agentcommand "agent-command"
"agent-system-backend/internal/cardsvc"
@@ -15,17 +16,23 @@ import (
"net/http"
"net/http/httptest"
"os"
osexec "os/exec"
"path/filepath"
"strings"
"testing"
"time"
agentllm "agent-native/runtime/llm"
nativetools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
skillseed "agent-system-backend/internal/skills"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
@@ -65,6 +72,62 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
return srv, st
}
// stubNativeStream 内核桩模型流(零网络):首轮按任务书「期望产出」逐个
// write_file 落盘(agent-native 契约:workspace 档步骤自己写交付物),
// 见到工具结果后给收尾文本。
func stubNativeStream(text string) agentllm.StreamFunc {
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 8)
go func() {
defer close(ch)
finish := func() {
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: text}}
}
prompt := ""
for _, m := range msgs {
if m.Role == agentllm.RoleTool {
finish()
return
}
if m.Role == agentllm.RoleUser {
prompt = m.Content
}
}
targets := stubTargets(prompt)
if len(targets) == 0 {
finish()
return
}
var calls []agentllm.ToolCall
for i, tgt := range targets {
args := fmt.Sprintf(`{"path":%q,"content":%q}`, tgt, text)
id := fmt.Sprintf("call_stub_%d", i+1)
ch <- agentllm.Event{Kind: agentllm.KindToolCallDelta, ToolIndex: i,
ToolID: id, ToolName: "write_file", ArgumentsDelta: args}
calls = append(calls, agentllm.ToolCall{ID: id, Name: "write_file", Arguments: args})
}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd,
Message: agentllm.Message{Role: agentllm.RoleAssistant, ToolCalls: calls, StopReason: agentllm.StopToolCalls}}
}()
return ch
}
}
// stubTargets 从任务书解析「期望产出:[a.md b.md]」。
func stubTargets(prompt string) []string {
i := strings.Index(prompt, "期望产出:[")
if i < 0 {
return nil
}
j := strings.Index(prompt[i:], "]")
if j < 0 {
return nil
}
return strings.Fields(prompt[i+len("期望产出:[") : i+j])
}
func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
@@ -853,9 +916,22 @@ func TestHITLIntentPivot(t *testing.T) {
}
// TestSquadOrchestration:Agent-Squad 引擎真实驱动——规则规划器四阶段计划、
// 每步经 "agent-system" 后端派生子代理(任务树)、计划事件、交付物工件。
// Q3 WP2 后:每步经 "agent-native" 内核后端在进程内执行(桩 Stream,零网络;
// SpawnSubagent 压缩路径已退役——步骤不再派生任务树子 Run)、计划事件、交付物工件。
func TestSquadOrchestration(t *testing.T) {
srv, _ := newTestServer(t)
skillsRoot := t.TempDir()
if _, serr := skillseed.Seed(skillsRoot); serr != nil {
t.Fatal(serr)
}
savedNative := squadadapter.AgentNativeOptions
squadadapter.AgentNativeOptions = func(squadadapter.Ctx) (nativeadp.Config, bool) {
py, _ := osexec.LookPath("python3")
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream(
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
}
defer func() { squadadapter.AgentNativeOptions = savedNative }()
h := srv.Handler()
projectID, convID := setupConversation(t, h)
runID := postMessage(t, h, convID, "#squad 生成一份示例目标简报")
@@ -889,17 +965,12 @@ func TestSquadOrchestration(t *testing.T) {
t.Fatalf("squad run status: %v (err: %v)", terminal["status"], terminal["error"])
}
// 1) 任务树:squad 步骤全部成为子代理 run。
// 1) 任务树:内核后端进程内执行步骤,不再派生子代理 run
//(SpawnSubagent 压缩路径退役的行为面)。
_, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil)
children := out["children"].([]any)
if len(children) < 3 {
t.Fatalf("expected >=3 squad step subagents, got %d", len(children))
}
for _, ci := range children {
c := ci.(map[string]any)
if c["kind"] != "subagent" || c["parentRunId"] != runID {
t.Errorf("bad squad child: %+v", c)
}
if len(children) != 0 {
t.Fatalf("kernel steps must not spawn subagent runs, got %d children", len(children))
}
// 2) 计划快照:四阶段骨架步骤映射到右栏计划。
@@ -1199,3 +1270,76 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
t.Fatalf("apiKey mask = %v", chat["apiKey"])
}
}
// TestUploadXlsxParsedAndIndexed:xlsx 上传走内置解析器(builtin:xlsx、无警告),
// 单元格文本进入知识检索(EXT-5 验收路径)。
func TestUploadXlsxParsedAndIndexed(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
projectID, _ := setupConversation(t, h)
xlsx := buildTestXLSX(t)
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
fw, _ := mw.CreateFormFile("file", "报价单.xlsx")
fw.Write(xlsx)
mw.Close()
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String())
}
up := map[string]any{}
json.Unmarshal(rec.Body.Bytes(), &up)
parsed, _ := up["parsed"].(map[string]any)
if parsed == nil {
t.Fatalf("upload response missing parsed: %s", rec.Body.String())
}
meta, _ := parsed["meta"].(map[string]any)
if meta["parser"] != "builtin:xlsx" {
t.Fatalf("parser: %v", meta["parser"])
}
if w, ok := parsed["warnings"].([]any); ok && len(w) > 0 {
t.Fatalf("xlsx upload should parse without warnings: %v", w)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
code, raw := doAny(t, h, "GET", "/api/knowledge/search?q=%E6%94%AF%E6%9E%B6", nil)
if code == 200 {
if hits, ok := raw.([]any); ok && len(hits) > 0 {
return
}
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("knowledge search found nothing for xlsx cell text")
}
// buildTestXLSX 构造最小可用 xlsx(inlineStr 单元格)。
func buildTestXLSX(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
files := map[string]string{
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价" sheetId="1" r:id="rId1"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>物料</t></is></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></c></row>
</sheetData></worksheet>`,
}
for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(files[name]))
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
+102
View File
@@ -0,0 +1,102 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
)
// EXT-9 验收:DisableUploadIndexing=true 时上传不再自动进检索(原件与摄取卡
// 行为不变);=false 恢复现状(可解析文本入索引)。
func TestUploadPreIndexSwitch(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON(t, map[string]string{"name": "EXT9"})))
req.Header.Set("Authorization", "Bearer "+alice)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
}
var pj map[string]any
json.Unmarshal(rec.Body.Bytes(), &pj)
pid := fmt.Sprint(pj["id"])
upload := func(name, text string) string {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("file", name)
fw.Write([]byte(text))
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
req.Header.Set("Authorization", "Bearer "+alice)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload %s: %d %s", name, rec.Code, rec.Body.String())
}
var up map[string]any
json.Unmarshal(rec.Body.Bytes(), &up)
return fmt.Sprint(up["artifactId"])
}
search := func(q string) int {
req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q="+q, nil)
req.Header.Set("Authorization", "Bearer "+alice)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
}
var arr []any
if err := json.Unmarshal(rec.Body.Bytes(), &arr); err == nil {
return len(arr) // 直接数组形态
}
var out struct {
Hits []any `json:"hits"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("search decode: %v (%s)", err, rec.Body.String())
}
return len(out.Hits)
}
// 开关开:上传不入检索,但原件完整可取回。
srv.DisableUploadIndexing = true
aid := upload("ext9-off.txt", "EXT9OFF 索引开关独占词甲")
if n := search("索引开关独占词甲"); n != 0 {
t.Fatalf("switch on: search hits=%d, want 0(存疑确认前不入正式检索)", n)
}
reqC := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+aid+"/content", nil)
reqC.Header.Set("Authorization", "Bearer "+alice)
recC := httptest.NewRecorder()
h.ServeHTTP(recC, reqC)
if recC.Code != http.StatusOK || !bytes.Contains(recC.Body.Bytes(), []byte("索引开关独占词甲")) {
t.Fatalf("original must remain retrievable: %d %q", recC.Code, recC.Body.String())
}
// 开关关(现状):同一上传路径恢复自动索引。
srv.DisableUploadIndexing = false
upload("ext9-on.txt", "EXT9ON 索引开关独占词乙")
if n := search("索引开关独占词乙"); n != 1 {
t.Fatalf("switch off: search hits=%d, want 1(现状自动索引)", n)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
+123
View File
@@ -0,0 +1,123 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
core "agent-system-core"
)
// EXT-8 资源授权适配器(红测:基础设施已就位、读取入口尚未咨询)——
// 默认拒绝保持现状;注册适配器后同项目成员可读共享资源、跨项目仍拒。
func TestProjectResourceAuthorizerSharesReads(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
carol, err := st.CreateAPIKey("carol", "e2e")
if err != nil {
t.Fatal(err)
}
// alice 建项目并上传一份工件(owner=alice)。
prj := struct {
Name string `json:"name"`
}{Name: "EXT8 共享项目"}
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(marshal(t, prj)))
req.Header.Set("Authorization", "Bearer "+alice)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
}
pid := decodeField(t, rec.Body.Bytes(), "id")
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("file", "共享说明.txt")
fw.Write([]byte("SHARED-CONTENT"))
mw.Close()
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
req.Header.Set("Authorization", "Bearer "+alice)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
}
aid := decodeField(t, rec.Body.Bytes(), "artifactId")
get := func(key, 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
}
// 默认拒绝(未注册适配器):bob 任何读取都是 NotFound,不泄露存在性。
for _, path := range []string{"/api/projects/" + pid, "/api/projects/" + pid + "/artifacts", "/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} {
if rec := get(bob, path); rec.Code != http.StatusNotFound {
t.Fatalf("default-deny: %s = %d, want 404", path, rec.Code)
}
}
// 注册适配器:bob 对该项目有共享授权;carol 无。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusOK {
t.Fatalf("shared artifact meta: %d %s", rec.Code, rec.Body.String())
}
rec = get(bob, "/api/artifacts/"+aid+"/content")
if rec.Code != http.StatusOK || rec.Body.String() != "SHARED-CONTENT" {
t.Fatalf("shared artifact content: %d %q", rec.Code, rec.Body.String())
}
rec = get(bob, "/api/projects/"+pid+"/artifacts")
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(aid)) {
t.Fatalf("shared artifact list: %d %s", rec.Code, rec.Body.String())
}
// 跨项目/未授权用户与错误路径仍拒(NotFound;错误回调 fail-closed)。
for _, path := range []string{"/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} {
if rec := get(carol, path); rec.Code != http.StatusNotFound {
t.Fatalf("carol must stay denied: %s = %d", path, rec.Code)
}
}
srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return false, io.ErrClosedPipe })
if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusNotFound {
t.Fatalf("authorizer error must fail closed: %d", rec.Code)
}
}
func marshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
func decodeField(t *testing.T, body []byte, field string) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("decode %s: %v (%s)", field, err, body)
}
return fmt.Sprint(m[field])
}
+154
View File
@@ -0,0 +1,154 @@
package server
import (
"net/http"
"os"
"path/filepath"
"testing"
"time"
"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"
core "agent-system-core"
)
// ---- N3 WP3:rewind HTTP 层(happy + busy 409)----
// happy:经 API 回滚被追踪写入,restored/skipped 上抛,文件内容退回。
func TestRewindFilesHTTPHappy(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() })
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
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.MockSubagent{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
ws := t.TempDir()
mgr.SetWorkspaceRoot(ws)
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)
h := srv.Handler()
_, convID := setupConversation(t, h)
runID := postMessage(t, h, convID, "写个文件")
// 等 run 终态(mock run 是异步的;不回等会撞到会话占用 409)。
deadline := time.Now().Add(2 * time.Second)
for {
run, err := st.GetRunByID(runID)
if err == nil && (run.Status == core.RunCompleted || run.Status == core.RunFailed || run.Status == core.RunCancelled) {
break
}
if time.Now().After(deadline) {
t.Fatalf("run 未终态:%+v", run)
}
time.Sleep(10 * time.Millisecond)
}
// 找到用户消息(anchor)并造一条被追踪写入。
var userMsgID string
msgs, err := st.ListMessages(convID)
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.Role == core.RoleUser {
userMsgID = m.ID
}
}
if userMsgID == "" {
t.Fatal("no user message")
}
sandbox := filepath.Join(ws, store.OwnerDefault, convID)
if err := os.MkdirAll(sandbox, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sandbox, "tracked.txt"), []byte("写入后"), 0o644); err != nil {
t.Fatal(err)
}
cpLog, err := mgr.OpenCheckpoints(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
if err := cpLog.Record(userMsgID, sandbox, "tracked.txt", []byte("写入前"), true, []byte("写入后")); err != nil {
t.Fatal(err)
}
// run 行已终态与释放会话占用之间有个 settle 窗口(CI 慢机上会撞 409):
// 有界重试直到放行。
var code int
var out map[string]any
deadline2 := time.Now().Add(3 * time.Second)
for {
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
if code != http.StatusConflict {
break
}
if time.Now().After(deadline2) {
break
}
time.Sleep(15 * time.Millisecond)
}
if code != http.StatusOK {
t.Fatalf("rewind status %d: %v", code, out)
}
restored, _ := out["restored"].([]any)
if len(restored) != 1 || restored[0] != "tracked.txt" {
t.Fatalf("restored: %v", out)
}
body, err := os.ReadFile(filepath.Join(sandbox, "tracked.txt"))
if err != nil || string(body) != "写入前" {
t.Fatalf("文件未退回:%v %q", err, body)
}
}
// busy:会话被占时回滚返回 409。
func TestRewindFilesHTTPBusy409(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() })
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}})
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
release := make(chan struct{})
blocking := blockingRunner{block: make(chan struct{}, 1), release: release}
defer close(release)
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, blocking, blocking, &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)
h := srv.Handler()
_, convID := setupConversation(t, h)
postMessage(t, h, convID, "先占住")
var userMsgID string
msgs, _ := st.ListMessages(convID)
for _, m := range msgs {
if m.Role == core.RoleUser {
userMsgID = m.ID
}
}
code, _ := doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
if code != http.StatusConflict {
t.Fatalf("busy rewind 应 409,得到 %d", code)
}
}
+136 -32
View File
@@ -27,6 +27,7 @@ import (
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/ratelimit"
"agent-system-backend/internal/route"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
@@ -56,6 +57,27 @@ type Server struct {
static fs.FS
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
// 摄取卡行为不变);组合根自 extension.Options 注入,零值=false=现状。
DisableUploadIndexing bool
// ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口
// owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。
ProjectAuthz func(owner string, projectID core.ID) (bool, error)
}
// SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。
func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) {
s.ProjectAuthz = fn
}
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
// (fail-closed;错误不区分,避免泄露存在性)。
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
if s.ProjectAuthz == nil {
return false
}
ok, err := s.ProjectAuthz(owner, projectID)
return err == nil && ok
}
func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
@@ -283,8 +305,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := s.st.GetProject(s.owner(r), pid); err != nil {
writeStoreErr(w, err)
return
// EXT-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。
if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) {
writeStoreErr(w, err)
return
}
}
conversationID = string(core.ProjectChannelKey(core.ID(pid)))
isProject = true
@@ -646,20 +671,35 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
// 先路由再落库。回合和工作流要先抢槽位,忙则 409 且不写任何数据。
// 意图命中的命令不占槽位,会话忙时也能用。
route := "turn"
// 路由裁决统一走唯一 Router(internal/route;I1 WP3)。
routeKind := route.KindTurn
var recognized intentgate.MatchResult
var workflowDef core.WorkflowDef
if s.Gate != nil && !strings.HasPrefix(text, "/") {
recognized = s.recognize(r.Context(), text)
if recognized.Branch == "matched" && s.Bus != nil && s.Bus.HasCommand(recognized.ID) {
route = "command"
} else if recognized.Branch == "matched" && s.Workflows != nil {
if _, ok := s.Workflows.ByIntent(recognized.ID); ok {
route = "workflow"
}
dec := route.Decide(recognized, route.Env{
HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) },
WorkflowFor: func(intentID string) (core.WorkflowDef, bool) {
if s.Workflows == nil {
return core.WorkflowDef{}, false
}
return s.Workflows.ByIntent(intentID)
},
// SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。
})
switch dec.Kind {
case route.KindCommand:
routeKind = route.KindCommand
recognized.ID = dec.CommandID
recognized.Slots = dec.Slots
case route.KindWorkflow:
routeKind = route.KindWorkflow
workflowDef = dec.Workflow
recognized.Slots = dec.Slots
}
}
claimed := false
if route == "turn" || route == "workflow" {
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
if err := s.mgr.ClaimConversation(convID); err != nil {
writeStoreErr(w, err)
return
@@ -682,7 +722,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if route == "command" {
if routeKind == route.KindCommand {
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID,
@@ -701,13 +741,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
}
var run core.Run
var startErr error
if route == "workflow" {
def, _ := s.Workflows.ByIntent(recognized.ID)
if routeKind == route.KindWorkflow {
missing := make([]workflow.SlotQuestion, len(recognized.Missing))
for i, q := range recognized.Missing {
missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question}
}
run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, def, recognized.Slots, missing)
run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, workflowDef, recognized.Slots, missing)
} else {
run, startErr = s.mgr.StartClaimedConversationRun(conv, msg, parsedDocs, inputs)
}
@@ -845,7 +884,7 @@ func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) {
// listAudits 审计查询(会话维度或全局;入口对称的可核查凭证)。
func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
audits, err := s.st.ListAudits(r.URL.Query().Get("conversationId"), limit)
audits, err := s.st.ListAudits(s.owner(r), r.URL.Query().Get("conversationId"), limit)
writeJSONOr(w, map[string]any{"audits": audits}, err)
}
@@ -1022,11 +1061,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
_ = s.Cards.Update(owner, ingestCard)
}
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
if s.Indexer != nil {
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
} else {
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
if !s.DisableUploadIndexing {
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
if s.Indexer != nil {
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
} else {
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
}
}
}
writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
@@ -1045,26 +1087,61 @@ func kindOf(filename string) string {
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
pid := r.PathValue("id")
if _, err := s.st.GetProject(owner, pid); err != nil {
// EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。
if !s.authorizeSharedProject(owner, core.ID(pid)) {
writeStoreErr(w, err)
return
}
as, err := s.st.ListProjectArtifactsAll(pid)
writeJSONOr(w, as, err)
return
}
as, err := s.st.ListArtifacts(owner, r.PathValue("id"))
as, err := s.st.ListArtifacts(owner, pid)
writeJSONOr(w, as, err)
}
// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器,
// 放行才返回;否则保持 NotFound(不泄露存在性)。
func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) {
a, err := s.st.GetArtifactByID(id)
if err != nil {
return core.Artifact{}, false
}
if !s.authorizeSharedProject(owner, a.ProjectID) {
return core.Artifact{}, false
}
return a, true
}
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id"))
owner := s.owner(r)
a, err := s.st.GetArtifact(owner, r.PathValue("id"))
if err != nil {
shared, ok := s.sharedArtifactGet(owner, r.PathValue("id"))
if !ok {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, shared)
return
}
writeJSONOr(w, a, err)
}
func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id"))
owner := s.owner(r)
a, err := s.st.GetArtifact(owner, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
shared, ok := s.sharedArtifactGet(owner, r.PathValue("id"))
if !ok {
writeStoreErr(w, err)
return
}
a = shared
}
content, err := s.st.ArtifactContent(s.owner(r), a.ID)
content, err := s.st.ArtifactContentByID(a.ID)
if err != nil {
writeStoreErr(w, err)
return
@@ -1164,7 +1241,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
// visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。
func (s *Server) visibleTools(owner string) []core.ToolDesc {
list := s.reg.List()
list := tools.FilterExecDisabled(s.reg.List())
if s.set.Effective(owner, "").GetBool("runtime.fsTools", false) {
return list
}
@@ -1396,12 +1473,39 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
}
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
if s.Retriever != nil {
hits, err := s.Retriever.Retrieve(r.Context(), s.owner(r), query)
owner := s.owner(r)
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
allow := func(docOwner, projectID core.ID) bool {
return s.authorizeSharedProject(owner, projectID)
}
if rs, ok := s.Retriever.(interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}); ok && s.Retriever != nil {
hits, err := rs.RetrieveShared(r.Context(), core.ID(owner), query, allow)
writeJSONOr(w, hits, err)
return
}
hits, err := s.st.SearchKnowledge(s.owner(r), query)
var hits []core.RetrievalHit
var err error
if s.Retriever != nil {
hits, err = s.Retriever.Retrieve(r.Context(), owner, query)
} else {
hits, err = s.st.SearchKnowledge(owner, query)
}
if err != nil {
writeStoreErr(w, err)
return
}
shared, serr := s.st.SearchKnowledgeShared(owner, query, allow)
if serr != nil {
writeStoreErr(w, serr)
return
}
hits = append(hits, shared...)
if limit := query.TopK; limit > 0 && len(hits) > limit {
hits = hits[:limit]
}
writeJSONOr(w, hits, err)
}
+223
View File
@@ -0,0 +1,223 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
core "agent-system-core"
)
// EXT-8 切片 2(红测):适配器放行后,他人 owner 的项目知识对本人可检索;
// 未放行/第三方保持零命中(不泄露存在性)。
func TestSharedKnowledgeRetrieval(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
// alice 建项目并写入带 ProjectID 的知识(模拟其已发布内容)。
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "S2"})))
req.Header.Set("Authorization", "Bearer "+alice)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create project: %d %s", rec.Code, rec.Body.String())
}
var pj map[string]any
readJSON(t, rec.Body.Bytes(), &pj)
pid := fmt.Sprint(pj["id"])
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: core.ID(pid),
Source: "plugin:rdm", Ref: "kb-1", Chunk: "共享检索独占词 甲"}); err != nil {
t.Fatal(err)
}
search := func(key string) int {
req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q=%E5%85%B1%E4%BA%AB%E6%A3%80%E7%B4%A2%E7%8B%AC%E5%8D%A0%E8%AF%8D", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
}
var hits []any
readJSON(t, rec.Body.Bytes(), &hits)
return len(hits)
}
if n := search(alice); n != 1 {
t.Fatalf("alice own search = %d, want 1", n)
}
if n := search(bob); n != 0 {
t.Fatalf("bob before grant = %d, want 0", n)
}
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
if n := search(bob); n != 1 {
t.Fatalf("bob after grant = %d, want 1(适配器放行后共享知识可检索)", n)
}
}
func readJSON(t *testing.T, b []byte, v any) {
t.Helper()
if err := json.Unmarshal(b, v); err != nil {
t.Fatalf("decode: %v (%s)", err, b)
}
}
func mustJSON2(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
// EXT-8 切片 2(红测):SSE 项目流订阅门——owner 自有未命中时咨询适配器;
// 未放行 404(不泄露存在性),放行可订阅(事件头即证)。
func TestProjectSSEScopeGate(t *testing.T) {
srv, st := newAuthTestServer(t)
_ = st
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "SSE"})))
req.Header.Set("Authorization", "Bearer "+alice)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var pj map[string]any
readJSON(t, rec.Body.Bytes(), &pj)
pid := fmt.Sprint(pj["id"])
srvSSE := httptest.NewServer(h)
t.Cleanup(srvSSE.Close)
sub := func(key string) int {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
srvSSE.URL+"/api/events?scope=project&projectId="+pid, nil)
req.Header.Set("Authorization", "Bearer "+key)
resp, err := srvSSE.Client().Do(req)
if err != nil {
if ctx.Err() != nil {
return http.StatusOK // 超时前未断流:视为已放行进入流(首字节已收)
}
t.Fatalf("sse: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
if c := sub(bob); c != http.StatusNotFound {
t.Fatalf("bob before grant: %d, want 404", c)
}
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
if c := sub(bob); c != http.StatusOK {
t.Fatalf("bob after grant: %d, want 200(适配器放行后可订阅项目流)", c)
}
if c := sub(alice); c != http.StatusOK {
t.Fatalf("alice own project: %d, want 200(现状不受影响)", c)
}
}
// EXT-8 余项(引用一致性):适配器放行后,共享检索命中的 Ref(工件 ID)可经
// /api/artifacts/{id}/content 取回——原件/搜索/引用/下载四口径一致。
func TestSharedCitationRefDownloadable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON2(t, map[string]string{"name": "引用链"})))
req.Header.Set("Authorization", "Bearer "+alice)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var pj map[string]any
readJSON(t, rec.Body.Bytes(), &pj)
pid := fmt.Sprint(pj["id"])
// alice 上传原件(走真实 upload,含索引),并以其 artifactId 为共享知识的 Ref。
up := uploadMultipart(t, h, alice, pid, "引用源.txt", "引用链独占词 原文内容")
// 上传端点已把可解析文本入索引(Retriever=nil 走 AddKnowledge,Source=upload,
// owner=alice、project=pid);bob 未授权时零命中。
if n := searchCount(t, h, bob, "引用链独占词"); n != 0 {
t.Fatalf("bob before grant = %d, want 0", n)
}
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
if n := searchCount(t, h, bob, "引用链独占词"); n != 1 {
t.Fatalf("bob after grant = %d, want 1(共享命中,Ref=工件)", n)
}
// 共享命中的 Ref(=上传工件 ID)可下载(引用→原件一致)。
reqC := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+up+"/content", nil)
reqC.Header.Set("Authorization", "Bearer "+bob)
recC := httptest.NewRecorder()
h.ServeHTTP(recC, reqC)
if recC.Code != http.StatusOK || !bytes.Contains(recC.Body.Bytes(), []byte("原文内容")) {
t.Fatalf("shared citation ref download: %d %q", recC.Code, recC.Body.String())
}
}
func uploadMultipart(t *testing.T, h http.Handler, key, pid, name, text string) string {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("file", name)
fw.Write([]byte(text))
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
}
var up struct {
ArtifactID string `json:"artifactId"`
}
readJSON(t, rec.Body.Bytes(), &up)
return up.ArtifactID
}
func searchCount(t *testing.T, h http.Handler, key, q string) int {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/knowledge/search?q=%E5%BC%95%E7%94%A8%E9%93%BE%E7%8B%AC%E5%8D%A0%E8%AF%8D", nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
}
var hits []any
readJSON(t, rec.Body.Bytes(), &hits)
return len(hits)
}
+4
View File
@@ -94,6 +94,10 @@ func Schema() []core.SettingDesc {
{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.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Description: "OTLP resource 的 service.name"},
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
@@ -1,4 +1,5 @@
---
name: docx-author
description: 用 housestyle 生成可直接交付的 Word(.docx)
---
@@ -1,4 +1,5 @@
---
name: house-style
description: 交付物设计系统。用 housestyle 生成 Word、Excel、PowerPoint,并在登记前自检。
---
@@ -1,4 +1,5 @@
---
name: pptx-author
description: 用 housestyle 生成 16:9 的 PowerPoint(.pptx)
---
@@ -1,4 +1,5 @@
---
name: xlsx-author
description: 用 housestyle 生成带表头、公式和说明页的 Excel(.xlsx)
---
+9
View File
@@ -12,6 +12,7 @@ import (
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
@@ -80,6 +81,14 @@ func Seed(dir string) (SeedResult, error) {
if err != nil {
return err
}
// SKILL.md 种子写入前过开放规范校验(recipes 全 dogfood 合规;
// 不合规拒写并上浮——错误不得静默落盘)。
if d.Name() == "SKILL.md" {
skillName := filepath.Base(filepath.Dir(rel))
if verr := validateBuiltin(skillName, data); verr != nil {
return fmt.Errorf("builtin skill %s: %w", skillName, verr)
}
}
embedded := hashBytes(data)
dst := filepath.Join(dir, filepath.FromSlash(rel))
disk, err := os.ReadFile(dst)
+23
View File
@@ -0,0 +1,23 @@
package skills
import (
"fmt"
agentskills "agent-native/runtime/skills"
)
// ValidateForPlugin 用 agent-native 的同一校验器验证插件技能
// (开放规范:name 必填且与目录同名、description 必填、已知键白名单)。
// 失败带插件上下文与字段定位,由调用方整批拒绝该插件(fail-closed,
// 与 agents/ 资产的禁带键同款形态)。
func ValidateForPlugin(pluginID, name string, body []byte) error {
if err := agentskills.ValidateSkillDocument(name, "plugins/"+pluginID+"/skills/"+name+".md", body); err != nil {
return fmt.Errorf("plugins: %s: skill %s: %w", pluginID, name, err)
}
return nil
}
// validateBuiltin 校验内置配方(种子写入前置;recipes 全部 dogfood 合规)。
func validateBuiltin(name string, body []byte) error {
return agentskills.ValidateSkillDocument(name, "recipes/"+name+"/SKILL.md", body)
}
+42 -87
View File
@@ -4,8 +4,8 @@
// 装配映射:
// - RoleRegistry → 内置小队角色(规则规划器自主编排四阶段骨架)
// - Planner → squad RulePlanner(确定性;LLM 规划器为后续档)
// - BackendResolver→ 自定义 "agent-system" 后端工厂:每步执行 = rc.SpawnSubagent
// (子 Run 进任务树,走既有 mock/LLM 子代理执行器与事件)
// - BackendResolver→ "agent-native" 内核后端工厂(Q3 WP2:SpawnSubagent
// 压缩路径已退役;每步执行 = agent-native 内核循环)
// - ProgressSink → 步骤事件映射 plan.update / agent.progress
// - HITL(挂起) → 引擎 Pending HumanRequest ↔ rc.AskHITL 卡片;RespondDecision 回填
package squadadapter
@@ -13,7 +13,6 @@ package squadadapter
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
@@ -69,14 +68,14 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
}
}
backends := buildBackends(rc)
backends := BuildBackends(rc)
engine, err := squad.NewEngine(squad.Options{
DataDir: runDir,
Registry: registry,
Planner: squad.NewRulePlanner(),
Backends: backends,
DefaultBackend: "agent-system",
DefaultBackend: "agent-native",
StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟)
HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive},
Checker: quality.Checker{},
@@ -147,70 +146,6 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) {
}
}
// stepBackend:小队步骤 → 统一子 Run(任务树可见;执行走 runmgr 的子代理执行器)。
type stepBackend struct {
rc Ctx
role squad.RoleSpec
}
func (b *stepBackend) Name() string { return "agent-system" }
// profileForRole 把小队角色键映射到子代理 profile。未列出的角色走 general。
func profileForRole(role string) string {
switch role {
case "planner", "organizer":
return "agent.sub.plan"
case "researcher", "analyst":
return "agent.sub.research"
case "writer":
return "agent.sub.writer"
case "reviewer", "leader":
return "agent.sub.verify"
default:
return "agent.sub.general"
}
}
func (b *stepBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
task := strings.TrimSpace(req.Prompt)
if task == "" {
task = req.Instructions
}
hint := strings.TrimSpace(req.Instructions)
name := firstNonEmpty(b.role.Name, b.role.Role)
var out core.AgentResultPayload
var err error
if sp, ok := b.rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
}); ok {
out, err = sp.SpawnAgent(profileForRole(b.role.Role), name, task, hint)
} else {
out, err = b.rc.SpawnSubagent(name, task, hint)
}
res := &squad.ExecResult{Success: err == nil, Logs: firstNonEmpty(out.Summary, "子代理完成")}
if err != nil {
res.ErrorReason = err.Error()
return res, nil
}
// 交付物:子代理全文写入步骤子工作区的目标文件(引擎契约:后端自行落盘,
// 检查器按 allTargetsPresent 校验;Patches 同时回传供引擎重试路径复用)。
content := firstNonEmpty(out.Text, out.Summary, "(子代理无文本产出)")
targets := req.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
res.Patches = make(map[string][]byte, len(targets))
for _, t := range targets {
body := []byte(content + "\n")
res.Patches[t] = body
dest := filepath.Join(req.WorkDir, filepath.FromSlash(t))
if merr := os.MkdirAll(filepath.Dir(dest), 0o755); merr == nil {
_ = os.WriteFile(dest, body, 0o644)
}
}
return res, nil
}
// askHuman:HumanRequest → HITL 卡片 → RespondDecision(选项直达 / 自由文本作答)。
func askHuman(rc Ctx, req *squad.HumanRequest) (squad.RespondDecision, error) {
choices := make([]core.HITLChoice, 0, len(req.Options))
@@ -313,6 +248,28 @@ func SubagentFactory(rc Ctx) subagent.Factory {
if len(req.Deliverables) > 0 {
b.WriteString("\n交付物:" + strings.Join(req.Deliverables, ", "))
}
// Q3 WP3:任务书四要素补全——目标、输出格式、工具和边界。空声明
// 不落节(沿用子代理画像默认)。
if strings.TrimSpace(req.OutputFormat) != "" {
b.WriteString("\n输出格式:" + req.OutputFormat)
}
if len(req.Tools) > 0 {
b.WriteString("\n工具面(可用工具,不得越界):" + strings.Join(req.Tools, ", "))
}
// Background(N2 WP5):声明驱动后台派生,语义同产品 delegate 的
// background 参数;完成结果经通知队列回投,本次调用立即返回 run ID。
// rc 无后台能力(测试桩/旧宿主)时回落同步派生,不 fail-closed。
if req.Background {
if bg, ok := rc.(interface {
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
}); ok {
id, err := bg.SpawnBackground("delegatee", "delegatee", b.String(), "内核 delegate_task 委派(后台)")
if err != nil {
return subagent.Result{}, err
}
return subagent.Result{Success: true, Text: "已在后台启动 " + id + ",完成后会自动通知"}, nil
}
}
payload, err := rc.SpawnSubagent("delegatee", b.String(), "内核 delegate_task 委派")
if err != nil {
return subagent.Result{}, err
@@ -328,15 +285,12 @@ func SubagentFactory(rc Ctx) subagent.Factory {
}
}
// buildBackends 组装小队后端解析器:"agent-system"(SpawnSubagent 路径)恒注册;
// 组合根注入 AgentNativeOptions 时额外注册 "agent-native" 内核工厂——角色显式
// 声明 Backend:"agent-native" 才会走内核执行;默认后端仍是 agent-system,
// 不因工厂注册而切换(对齐 parity 渐进启用条款)。
func buildBackends(rc Ctx) *squad.BackendResolver {
// BuildBackends 组装小队后端解析器(Q3 WP2):只注册 "agent-native" 内核
// 工厂(组合根注入 AgentNativeOptions 且模型已配置时)。legacy 的
// "agent-system"(SpawnSubagent 压缩路径)已退役删除;工厂缺席时解析
// fail-closed——步骤失败可见,不静默降级到别的执行面。
func BuildBackends(rc Ctx) *squad.BackendResolver {
backends := squad.NewBackendResolver()
backends.Register("agent-system", func(role squad.RoleSpec) (squad.Executor, error) {
return &stepBackend{rc: rc, role: role}, nil
})
if AgentNativeOptions != nil {
if cfg, ok := AgentNativeOptions(rc); ok {
nativeadp.RegisterFull(backends, cfg)
@@ -358,23 +312,24 @@ func builtinRoles() []squad.RoleSpec {
}
return fallback
}
// 执行配置显式化(agent-native 后端消费;agent-system 路径忽略不生效但保持声明一致):
// 执行配置显式化(agent-native 后端消费):
// 规划/调研/审校一律 BashPermission=deny(无命令执行需求);
// 撰稿员需要 run_python 生成文档——ask 档(每次运行首次使用前人工放行)。
// 工具白名单按角色最小化;撰稿员技能指向启动种子的三套交付配方。
// 撰稿员需要 run_python/run_command 生成文档——ask 档(每次运行首次使用前人工放行)。
// 工具白名单按角色最小化(Q3 WP2:agent-native 下步骤须自己落盘交付物,
// 全员补 write_file);撰稿员技能指向启动种子的三套交付配方。
return []squad.RoleSpec{
{Role: "planner", Name: "规划员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "list_dir", "grep"},
{Role: "planner", Name: "规划员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "list_dir", "grep", "write_file"},
Keywords: []string{"梳理", "规划", "分析"}, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")},
{Role: "researcher", Name: "调研员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
{Role: "researcher", Name: "调研员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: []string{"web_search", "tavily_search", "fetch_url", "read_file", "write_file"},
Keywords: []string{"调研", "检索", "资料"}, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")},
{Role: "writer", Name: "撰稿员", Backend: "agent-system", BashPermission: squad.BashPermissionAsk,
Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_shell"},
{Role: "writer", Name: "撰稿员", Backend: "agent-native", BashPermission: squad.BashPermissionAsk,
Tools: []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "load_skill", "run_python", "run_command"},
Skills: []string{"docx-author", "xlsx-author", "pptx-author"},
Keywords: []string{"撰写", "成文", "报告"}, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")},
{Role: "reviewer", Name: "审校员", Backend: "agent-system", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "grep", "glob", "list_dir"},
{Role: "reviewer", Name: "审校员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny,
Tools: []string{"read_file", "grep", "glob", "list_dir", "write_file"},
Keywords: []string{"审校", "把关", "终审"}, Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
}
}
+19 -50
View File
@@ -36,8 +36,8 @@ func TestBuiltinRolesValid(t *testing.T) {
if err := reg.Register(r); err != nil {
t.Errorf("role %s invalid: %v", r.Role, err)
}
if r.Backend != "agent-system" {
t.Errorf("role %s backend: %s", r.Role, r.Backend)
if r.Backend != "agent-native" {
t.Errorf("role %s backend: %s", r.Backend, r.Backend)
}
}
}
@@ -85,6 +85,8 @@ func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
Goal: "查证数据", Inputs: "docs/v2", WriteScope: "research/",
NonGoals: "不做实现", DoneCriteria: "三来源对照",
Deliverables: []string{"notes.md"},
OutputFormat: "Markdown:结论先行,附对照表",
Tools: []string{"web_search", "read_file"},
})
if err != nil {
t.Fatal(err)
@@ -92,7 +94,8 @@ func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
if !res.Success || res.Text != "子代理结论" {
t.Fatalf("bridge result wrong: %+v", res)
}
for _, want := range []string{"查证数据", "docs/v2", "research/", "不做实现", "三来源对照", "notes.md"} {
for _, want := range []string{"查证数据", "docs/v2", "research/", "不做实现", "三来源对照", "notes.md",
"输出格式:Markdown:结论先行,附对照表", "工具面(可用工具,不得越界):web_search, read_file"} {
if !strings.Contains(rc.gotTask, want) {
t.Fatalf("task brief missing %q:\n%s", want, rc.gotTask)
}
@@ -115,53 +118,6 @@ func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
}
}
type agentFakeRc struct {
subagentFakeRc
agentID string
}
func (f *agentFakeRc) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
f.agentID = agentID
f.gotName, f.gotTask = name, task
return f.payload, f.err
}
func TestStepRoleMapsToProfile(t *testing.T) {
dir := t.TempDir()
cases := []struct {
role string
want string
}{
{"planner", "agent.sub.plan"},
{"organizer", "agent.sub.plan"},
{"researcher", "agent.sub.research"},
{"analyst", "agent.sub.research"},
{"writer", "agent.sub.writer"},
{"reviewer", "agent.sub.verify"},
{"leader", "agent.sub.verify"},
{"specialist", "agent.sub.general"},
}
for _, tc := range cases {
rc := &agentFakeRc{subagentFakeRc: subagentFakeRc{payload: core.AgentResultPayload{Text: "ok"}}}
b := &stepBackend{rc: rc, role: squad.RoleSpec{Role: tc.role, Name: "显示名"}}
res, err := b.Execute(context.Background(), squad.ExecRequest{Prompt: "做这件事", WorkDir: dir, Targets: []string{"output.md"}})
if err != nil || !res.Success {
t.Fatalf("%s: %+v %v", tc.role, res, err)
}
if rc.agentID != tc.want || rc.gotName != "显示名" {
t.Fatalf("%s mapped to %s name %s", tc.role, rc.agentID, rc.gotName)
}
}
plain := &subagentFakeRc{payload: core.AgentResultPayload{Text: "ok"}}
b := &stepBackend{rc: plain, role: squad.RoleSpec{Role: "writer", Name: "撰稿"}}
if _, err := b.Execute(context.Background(), squad.ExecRequest{Prompt: "写", WorkDir: dir}); err != nil {
t.Fatal(err)
}
if plain.gotName != "撰稿" {
t.Fatalf("fallback name = %s", plain.gotName)
}
}
func TestBuiltinRolesExplicitExecConfig(t *testing.T) {
roles := builtinRoles()
byKey := map[string]squad.RoleSpec{}
@@ -210,3 +166,16 @@ func containsStr(list []string, want string) bool {
}
return false
}
// Q3 WP2:agent-system 退役后,未注入 AgentNativeOptions 时解析器为空——
// 步骤后端解析 fail-closed,不再静默走 SpawnSubagent 压缩路径。
func TestBuildBackendsWithoutNativeOptionsIsEmpty(t *testing.T) {
saved := AgentNativeOptions
AgentNativeOptions = nil
defer func() { AgentNativeOptions = saved }()
r := BuildBackends(&subagentFakeRc{})
if _, _, err := r.Resolve(squad.RoleSpec{Role: "writer", Backend: "agent-native"}, "agent-native"); err == nil {
t.Fatal("无内核工厂时解析必须 fail-closed")
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) {
return EntryResult{}, fmt.Errorf("squad entry: register %s: %w", role.Role, err)
}
}
backends := buildBackends(rc)
backends := BuildBackends(rc)
nativeadp.RegisterFull(backends, cfg)
checker := opt.Checker
@@ -0,0 +1,99 @@
package squadadapter
import (
"context"
"fmt"
osexec "os/exec"
"strings"
"testing"
nativetools "agent-native/runtime/tools"
agentllm "agent-native/runtime/llm"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/skills"
core "agent-system-core"
)
type orchFakeRc struct {
subagentFakeRc
dir string
}
func (f *orchFakeRc) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
if len(card.Choices) > 0 {
return core.HITLResponse{ChoiceID: card.Choices[0].ID}, nil
}
return core.HITLResponse{ChoiceID: "approve"}, nil
}
// TestOrchestrateRunsOnKernelStub Q3 WP2 行为面:agent-system 退役后,
// Orchestrate 的四阶段骨架经 agent-native 内核(桩 Stream + write_file 落盘
// 交付物 + 种子技能 + Exec 白名单)跑通并产出交付物工件。
func TestOrchestrateRunsOnKernelStub(t *testing.T) {
skillsRoot := t.TempDir()
if _, serr := skills.Seed(skillsRoot); serr != nil {
t.Fatal(serr)
}
saved := AgentNativeOptions
AgentNativeOptions = func(Ctx) (nativeadp.Config, bool) {
py, _ := osexec.LookPath("python3")
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubStream("# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
}
defer func() { AgentNativeOptions = saved }()
rc := &orchFakeRc{dir: t.TempDir()}
dataDir := t.TempDir()
summary, err := Orchestrate(rc, dataDir, "生成一份示例目标简报")
t.Logf("summary = %q", summary)
if err != nil {
t.Fatalf("err = %v", err)
}
if !strings.Contains(summary, "completed") || !strings.Contains(summary, "完成 4") {
t.Fatalf("summary = %q", summary)
}
}
func stubStream(text string) agentllm.StreamFunc {
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 8)
go func() {
defer close(ch)
finish := func() {
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: text}}
}
prompt := ""
for _, m := range msgs {
if m.Role == agentllm.RoleTool {
finish()
return
}
if m.Role == agentllm.RoleUser {
prompt = m.Content
}
}
i := strings.Index(prompt, "期望产出:[")
if i < 0 {
finish()
return
}
j := strings.Index(prompt[i:], "]")
if j < 0 {
finish()
return
}
targets := strings.Fields(prompt[i+len("期望产出:[") : i+j])
var calls []agentllm.ToolCall
for k, tgt := range targets {
args := fmt.Sprintf(`{"path":%q,"content":%q}`, tgt, text)
id := fmt.Sprintf("call_stub_%d", k+1)
ch <- agentllm.Event{Kind: agentllm.KindToolCallDelta, ToolIndex: k, ToolID: id, ToolName: "write_file", ArgumentsDelta: args}
calls = append(calls, agentllm.ToolCall{ID: id, Name: "write_file", Arguments: args})
}
ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, ToolCalls: calls, StopReason: agentllm.StopToolCalls}}
}()
return ch
}
}
+18 -9
View File
@@ -8,21 +8,30 @@ import (
agentcommand "agent-command"
)
// AppendAudit 追加命令审计信封(cmdbus 端口;v7 command_audit)。
func (s *Store) AppendAudit(env agentcommand.AuditEnvelope) error {
// AppendAudit 追加命令审计信封(cmdbus 端口;v7 建表,v18 增加 owner 列)。
// owner 是租户身份(认证注入),与信封里的 Actor(发起主体)语义不同:查询按
// owner 过滤,跨租户审计不可见。
func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
raw, err := json.Marshal(env)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State),
env.OperationID, env.ConversationID, string(raw))
env.OperationID, env.ConversationID, owner, string(raw))
return err
}
// ListAudits 审计查询(时间倒序;conversationID 空 = 全局)。
func (s *Store) ListAudits(conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) {
// ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。
// owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行
// 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。
// v18 回填时 actor 为空的存量行 owner 保持空串,不匹配任何租户:这些行不再经
// API 返回(数据保留在表内,可直查数据库取证)——见 v18 迁移注释。
func (s *Store) ListAudits(owner, conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) {
if owner == "" {
return []agentcommand.AuditEnvelope{}, nil
}
if limit <= 0 || limit > 500 {
limit = 100
}
@@ -31,9 +40,9 @@ func (s *Store) ListAudits(conversationID string, limit int) ([]agentcommand.Aud
err error
)
if conversationID == "" {
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit ORDER BY time DESC LIMIT ?`), limit)
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit)
} else {
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE conversation_id=? ORDER BY time DESC LIMIT ?`), conversationID, limit)
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? AND conversation_id=? ORDER BY time DESC LIMIT ?`), owner, conversationID, limit)
}
if err != nil {
return nil, err
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
)
// v18 迁移:command_audit 增加 owner 列并按 payload.actor 回填存量行
// (GET /api/commands/audit 曾跨租户可读;见 migrate.go v18 注释)。
// 路径:全新库(已到 v18)→ MigrateDown(17) 回出无 owner 列的旧表 →
// 直插两行修复前形态的信封 → 重开触发 v18 → 归属断言。
func TestCommandAuditOwnerBackfill(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
if err := st.MigrateDown(17); err != nil {
t.Fatalf("migrate down to v17: %v", err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
legacy := func(actor, cmd string) string {
// 修复前形态:信封 Actor 即发起租户,表内无归属列。
return `{"time":"` + now + `","actor":"` + actor + `","commandId":"` + cmd + `","source":"builtin","state":"committed"}`
}
if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload)
VALUES (?, 'cmd.a', 'builtin', 'api', 'committed', '', 'c1', ?)`), now, legacy("alice", "cmd.a")); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload)
VALUES (?, 'cmd.x', 'builtin', 'api', 'committed', '', '', ?)`), now, `{"time":"`+now+`","commandId":"cmd.x","state":"committed"}`); err != nil {
t.Fatal(err) // actor 为空的存量行:无法归属
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st2, err := OpenSQLite(path) // 重开触发 v18:加列 + 回填
if err != nil {
t.Fatal(err)
}
defer st2.Close()
aliceRows, err := st2.ListAudits("alice", "", 10)
if err != nil {
t.Fatal(err)
}
if len(aliceRows) != 1 || aliceRows[0].CommandID != "cmd.a" {
t.Fatalf("alice rows = %+v, want 1 条 cmd.a(actor 回填归属)", aliceRows)
}
bobRows, err := st2.ListAudits("bob", "", 10)
if err != nil {
t.Fatal(err)
}
if len(bobRows) != 0 {
t.Fatalf("bob rows = %+v, want 0", bobRows)
}
if rows, _ := st2.ListAudits("", "", 10); len(rows) != 0 {
t.Fatalf("空 owner(actor 缺失的存量行)不得经 API 归属返回,got %d 行", len(rows))
}
// 新写入走 owner 列;同租户按会话过滤仍可用。
if err := st2.AppendAudit("alice", agentcommand.AuditEnvelope{CommandID: "cmd.a2", Actor: "alice",
State: agentcommand.StateCommitted, ConversationID: "c2"}); err != nil {
t.Fatal(err)
}
convRows, err := st2.ListAudits("alice", "c2", 10)
if err != nil {
t.Fatal(err)
}
if len(convRows) != 1 || convRows[0].CommandID != "cmd.a2" {
t.Fatalf("alice/c2 rows = %+v, want cmd.a2", convRows)
}
}
+174 -6
View File
@@ -111,6 +111,51 @@ func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error)
return out, rows.Err()
}
// ---- EXT-8 共享读取助手:无 owner 过滤,仅供 HTTP 层在
// authorizeSharedProject 判定通过后使用;其他调用点一律禁止(会破坏租户边界)。----
// GetArtifactByID 按工件 ID 取元数据(无 owner 过滤;调用方须先过 EXT-8 判定)。
func (s *Store) GetArtifactByID(id string) (core.Artifact, error) {
a, err := scanArtifact(func(dst ...any) error {
return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`), id).Scan(dst...)
})
if errors.Is(err, sql.ErrNoRows) {
return a, ErrNotFound
}
return a, err
}
// ArtifactContentByID 按工件 ID 取内容(无 owner 过滤;调用方须先过 EXT-8 判定)。
func (s *Store) ArtifactContentByID(id string) ([]byte, error) {
var ref string
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=?`), id).Scan(&ref)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return s.blobs.Get(ref)
}
// ListProjectArtifactsAll 项目全部工件(含其他 owner;调用方须先过 EXT-8 判定)。
func (s *Store) ListProjectArtifactsAll(projectID string) ([]core.Artifact, error) {
rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`), projectID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Artifact{}
for rows.Next() {
a, err := scanArtifact(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。
func (s *Store) DeleteArtifact(owner, id string) error {
var path string
@@ -267,6 +312,40 @@ func (s *Store) DeleteKnowledgeByRef(owner, ref string) error {
return err
}
// DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配
// 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在
// KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。
func (s *Store) DeleteKnowledgeBySource(ctx context.Context, owner, sourcePrefix string) (int64, error) {
rows, err := s.db.QueryContext(ctx, s.q(`SELECT DISTINCT ref FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%")
if err != nil {
return 0, err
}
refs := []string{}
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
rows.Close()
return 0, err
}
refs = append(refs, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, r := range refs {
if err := s.DeleteKnowledgeVectors(ctx, owner, r); err != nil {
return 0, err
}
}
res, err := s.db.ExecContext(ctx, s.q(`DELETE FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%")
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if s.dialect.name() == "postgres" {
return s.searchKnowledgePG(owner, q)
@@ -274,7 +353,72 @@ func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.Ret
return s.searchKnowledgeSQLite(owner, q)
}
// knowledgePred 命中可见性谓词(owner 作用域或共享作用域由调用方决定)。
type knowledgePred func(d core.KnowledgeDoc) bool
func ownerVisible(owner string, q core.RetrievalQuery) knowledgePred {
return func(d core.KnowledgeDoc) bool { return knowledgeVisible(owner, q, d) }
}
// sharedVisible EXT-8 切片 2:他人 owner 且其 ProjectID 经授权回调放行的行可见;
// 无 ProjectID 的行无法授权(拒绝);项目/来源过滤沿用查询口径。
func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) knowledgePred {
return func(d core.KnowledgeDoc) bool {
if d.Owner == "" || d.Owner == requester || d.ProjectID == "" {
return false
}
if q.ProjectID != "" && d.ProjectID != q.ProjectID {
return false
}
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
return false
}
return allow(d.Owner, d.ProjectID)
}
}
// SearchKnowledgeShared 共享检索(EXT-8 切片 2):返回他人 owner 的项目知识中,
// 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。
// allow 出错由调用方前置处理——本层只见布尔。
func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
pred := sharedVisible(requester, q, allow)
if s.dialect.name() == "postgres" {
return s.searchKnowledgePGShared(requester, q, pred)
}
return s.searchKnowledgeSQLiteShared(requester, q, pred)
}
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q)
if err != nil {
return nil, err
}
// CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
if len(out) == 0 && q.Text != "" {
topK := q.TopK
if topK <= 0 {
topK = 5
}
return s.searchKnowledgeLike(owner, q, topK)
}
return out, nil
}
// searchKnowledgeSQLiteShared 共享版:FTS SQL 本身无 owner 预过滤(可见性原为
// 后置判定),直接换谓词即可;LIKE 兜底换“他人行”预过滤。
func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
out, err := s.searchKnowledgeFTS(pred, q)
if err != nil {
return nil, err
}
if len(out) == 0 && q.Text != "" {
return s.searchKnowledgeLikeShared(requester, q, pred)
}
return out, nil
}
// searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。
func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
@@ -293,7 +437,7 @@ func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]co
if err != nil {
return nil, err
}
if !knowledgeVisible(owner, q, h.Doc) {
if !pred(h.Doc) {
continue
}
out = append(out, h)
@@ -301,14 +445,38 @@ func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]co
break
}
}
if err := rows.Err(); err != nil {
return out, rows.Err()
}
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
requester, "%"+q.Text+"%", "%"+q.Text+"%", topK)
if err != nil {
return nil, err
}
// CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
if len(out) == 0 && q.Text != "" {
return s.searchKnowledgeLike(owner, q, topK)
defer rows.Close()
out := []core.RetrievalHit{}
for rows.Next() {
var d core.KnowledgeDoc
var own, ca string
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
return nil, err
}
d.Owner = own
d.CreatedAt, _ = parseTime(ca)
if !pred(d) {
continue
}
out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"})
if len(out) >= topK {
break
}
}
return out, nil
return out, rows.Err()
}
func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
+42
View File
@@ -404,9 +404,51 @@ func init() {
migration{version: 16, name: "secrets_context_and_variants",
up: map[string][]string{"sqlite": v16Up, "postgres": v16Up},
down: map[string][]string{"sqlite": v16Down, "postgres": v16Down}},
migration{version: 17, name: "pending_notices",
up: map[string][]string{"sqlite": v17Up, "postgres": v17Up},
down: map[string][]string{"sqlite": v17Down, "postgres": v17Down}},
migration{version: 18, name: "command_audit_owner",
// 命令审计租户归属(GET /api/commands/audit 曾无 owner 过滤,跨租户可读)。
// 存量行回填取 payload.actor——修复前行未记租户,Actor 是唯一可用线索且
// 对历史 HTTP 入口恒等于发起租户;actor 为空的行无法归属,owner 留空,
// API 查询不再返回(数据保留在表内,可直查数据库取证)。索引覆盖
// owner×conversation×time 的两种查询形态。
up: map[string][]string{"sqlite": {
`ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
`UPDATE command_audit SET owner = COALESCE(json_extract(payload, '$.actor'), '') WHERE owner = ''`,
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`,
}, "postgres": {
`ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
`UPDATE command_audit SET owner = COALESCE(payload::json->>'actor', '') WHERE owner = ''`,
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`,
}},
// down:先删引用列的索引再 DROP COLUMN(sqlite 对被索引列直接 DROP
// 会报 no such column: owner;PG 先删同样无害)。
down: map[string][]string{"sqlite": {
`DROP INDEX IF EXISTS idx_audit_owner`,
`ALTER TABLE command_audit DROP COLUMN owner`,
}, "postgres": {
`DROP INDEX IF EXISTS idx_audit_owner`,
`ALTER TABLE command_audit DROP COLUMN owner`,
}}},
)
}
// v17 后台子代理完成通知的持久化队列(N2 WP5:重启后重投未投递结果)。
var v17Up = []string{
`CREATE TABLE IF NOT EXISTS pending_notices (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_pending_notices_conv ON pending_notices(conversation_id, created_at)`,
}
var v17Down = []string{`DROP TABLE IF EXISTS pending_notices`}
// v16 密钥独立表、设置快照、条件注入、用户命令、会话要求、消息排除与候选回复。
var v16Up = []string{
`CREATE TABLE IF NOT EXISTS secret_keys (
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"database/sql"
"errors"
"time"
core "agent-system-core"
)
// Notice 是一条待投递的后台子代理完成通知(N2 WP5)。
type Notice struct {
ID string
ConversationID string
Owner string
Name string
Payload core.AgentResultPayload
CreatedAt time.Time
}
// AppendNotice 落一条待投递通知(重启后可重投)。
func (s *Store) AppendNotice(n Notice) error {
_, err := s.db.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?)`),
n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(&n.Payload), nowISO())
return err
}
// ListNotices 取回全部待投递通知(Manager 启动时装入内存队列)。
func (s *Store) ListNotices() ([]Notice, error) {
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,owner,name,payload,created_at FROM pending_notices ORDER BY created_at`))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Notice
for rows.Next() {
var n Notice
var payload, ca string
if err := rows.Scan(&n.ID, &n.ConversationID, &n.Owner, &n.Name, &payload, &ca); err != nil {
return nil, err
}
n.Payload, _ = unmarshalJSON[core.AgentResultPayload](payload)
n.CreatedAt, _ = parseTime(ca)
out = append(out, n)
}
return out, rows.Err()
}
// DeleteNotice 投递成功后删除(按 id 幂等)。
func (s *Store) DeleteNotice(id string) error {
_, err := s.db.Exec(s.q(`DELETE FROM pending_notices WHERE id=?`), id)
return err
}
// DeleteNotices 批量删除(投递成功批)。
func (s *Store) DeleteNotices(ids []string) error {
for _, id := range ids {
if err := s.DeleteNotice(id); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
}
return nil
}
+20
View File
@@ -195,3 +195,23 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message) (bool, error) {
}
return n == 1, nil
}
// ListInterruptedSubagentRuns 列出被收成 interrupted 的子代理 run
// (N2 WP5 重启续跑的候选集;幂等过滤由转录目录的 done/resumed 标记完成)。
func (s *Store) ListInterruptedSubagentRuns() ([]core.Run, error) {
rows, err := s.db.Query(s.q(`SELECT `+runColumns+` FROM runs WHERE kind=? AND status=? ORDER BY created_at`),
string(core.RunSubagent), string(core.RunInterrupted))
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Run{}
for rows.Next() {
r, err := scanRun(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
+45
View File
@@ -49,3 +49,48 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R
}
return out, rows.Err()
}
// searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定
// (allow 回调由上层注入;排序与自检索同构)。
func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
text := q.Text
rows, err := s.db.Query(s.q(`
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at,
ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r
FROM knowledge k
WHERE (k.owner<>? AND k.owner<>'')
AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?)
ORDER BY r DESC, k.created_at DESC
LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.RetrievalHit{}
for rows.Next() {
var d core.KnowledgeDoc
var own, ca string
var rank float64
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &rank); err != nil {
return nil, err
}
d.Owner = own
d.CreatedAt, _ = parseTime(ca)
if !pred(d) {
continue
}
score := rank
if score == 0 {
score = 0.5
}
out = append(out, core.RetrievalHit{Doc: d, Score: score, Kind: "fulltext"})
if len(out) >= topK {
break
}
}
return out, rows.Err()
}
+2 -2
View File
@@ -71,8 +71,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(got) != 16 {
t.Fatalf("expected versions [1..16], got %v", got)
if len(got) != 18 {
t.Fatalf("expected versions [1..18], got %v", got)
}
// 老库形态(v2 前):events 全局自增、settings 单列主键。
if err := st.MigrateDown(1); err != nil {
+55
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/binary"
"math"
"sort"
core "agent-system-core"
)
@@ -61,6 +62,60 @@ func (s *Store) DeleteKnowledgeVectors(ctx context.Context, owner, ref string) e
// SearchKnowledgeVectors 余弦扫描:候选 = 租户本人 + 共享(owner=”),dim 匹配,
// 再按 RetrievalQuery 的项目/来源过滤。
// SearchKnowledgeVectorsShared EXT-8 余项:他人 owner 的向量行 + 谓词后置判定
// (allow 由上层注入适配器;无 ProjectID 行拒绝)。结构同自检索(余弦扫描)。
func (s *Store) SearchKnowledgeVectorsShared(ctx context.Context, requester string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
if topK <= 0 {
topK = 5
}
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec
FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id
WHERE (v.owner<>? AND v.owner<>'') AND v.dim=?`), requester, len(vec))
if err != nil {
return nil, err
}
defer rows.Close()
type cand struct {
hit core.RetrievalHit
sim float64
}
var cands []cand
for rows.Next() {
var d core.KnowledgeDoc
var own, ca string
var blob []byte
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &blob); err != nil {
return nil, err
}
d.Owner = own
d.CreatedAt, _ = parseTime(ca)
if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) {
continue
}
if filter.ProjectID != "" && d.ProjectID != filter.ProjectID {
continue
}
if len(filter.Sources) > 0 && !containsStr(filter.Sources, d.Source) {
continue
}
sim := cosine(vec, decodeVector(blob))
cands = append(cands, cand{hit: core.RetrievalHit{Doc: d, Score: sim, Kind: "vector"}, sim: sim})
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.SliceStable(cands, func(i, j int) bool { return cands[i].sim > cands[j].sim })
out := []core.RetrievalHit{}
for _, c := range cands {
out = append(out, c.hit)
if len(out) >= topK {
break
}
}
return out, nil
}
func (s *Store) SearchKnowledgeVectors(ctx context.Context, owner string, vec []float32, topK int, filter core.RetrievalQuery) ([]core.RetrievalHit, error) {
if topK <= 0 {
topK = 5
+36 -1
View File
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
core "agent-system-core"
)
@@ -26,8 +27,42 @@ const (
execSetting = "runtime.exec"
)
// execGate runtime.exec 总闸(默认开;false = 执行原语整体禁用,fail-closed)。
// execHardOff 启动级硬关闭(组合根依启动参数设置)。runtime.exec 是用户可写的
// 业务设置,多用户部署时任何人都能把它改回 true;硬关闭不读设置,设置无法覆盖。
var execHardOff atomic.Bool
// SetExecDisabled 设置启动级硬关闭;仅组合根在装配期调用。
func SetExecDisabled(off bool) { execHardOff.Store(off) }
// ExecDisabled 报告是否处于启动级硬关闭。
func ExecDisabled() bool { return execHardOff.Load() }
// execToolNames 受硬关闭约束的执行原语。
var execToolNames = map[string]bool{"run_shell": true, "run_python": true, "register_artifact": true}
// IsExecTool 报告工具是否属于执行原语族。
func IsExecTool(name string) bool { return execToolNames[name] }
// FilterExecDisabled 硬关闭时从工具清单移除执行原语(可见性一侧;执行一侧由 execGate 拒绝)。
func FilterExecDisabled(list []core.ToolDesc) []core.ToolDesc {
if !ExecDisabled() {
return list
}
out := make([]core.ToolDesc, 0, len(list))
for _, t := range list {
if !IsExecTool(t.Name) {
out = append(out, t)
}
}
return out
}
// execGate 执行原语总闸:启动级硬关闭优先;其后 runtime.exec(默认开;false =
// 执行原语整体禁用,fail-closed)。
func execGate(rc ToolExecCtx) error {
if ExecDisabled() {
return fmt.Errorf("exec tools: 已被启动参数禁用(disableExecTools)")
}
if !rc.SettingBool(execSetting, true) {
return fmt.Errorf("exec tools: 已被设置禁用(%s=false)", execSetting)
}
+4 -1
View File
@@ -321,8 +321,11 @@ func firstPos(vals ...int) int {
// InterpreterExecConfig 把产品解释器白名单换算为 agent-native 内核的
// ExecConfig(squad 角色经 agent-native 后端执行时复用同一套约束与环境)。
// 两个解释器都缺席时返回 nil(内核侧不注册执行工具,fail-closed)。
// 两个解释器都缺席或启动级硬关闭时返回 nil(内核侧不注册执行工具,fail-closed)。
func InterpreterExecConfig() *agenttools.ExecConfig {
if ExecDisabled() {
return nil
}
cfg := &agenttools.ExecConfig{Env: execEnv()}
if p, err := exec.LookPath("bash"); err == nil {
cfg.Bash = p

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