diff --git a/internal/app/app.go b/internal/app/app.go index e006db4..26afd6b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -675,6 +675,10 @@ func Run(opt Options) error { // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) compiledSummary := applyCompiled(skillsRoot, bus, caps) + // EXT-20:法规检索作为内置只读注册表工具暴露给模型(走 inprocHost 审计路径)。 + if err := registerRegulationsTools(reg, caps); err != nil { + return fmt.Errorf("regulations tools: %w", err) + } if err := requireProjectContextProvider(opt.RequireProjectContext, contextSlot); err != nil { return err } diff --git a/internal/app/regulations_tools.go b/internal/app/regulations_tools.go new file mode 100644 index 0000000..731bcd2 --- /dev/null +++ b/internal/app/regulations_tools.go @@ -0,0 +1,217 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "agent-system-backend/internal/tools" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// EXT-20 内置对话工具:模型经工具注册表只读检索共享法规知识库。执行走 +// inprocHost 的审计路径(有效账号校验 + regulations.* 审计),调用方身份为 +// 运行属主本人与固定的内置工具调用方 ID。 + +const ( + regulationsToolCallerID = "agent-tools" + regulationsToolTimeout = 60 * time.Second + regulationsSearchDefaultLimit = 5 + regulationsSearchMaxLimit = 10 + + toolRegulationsSearch = "regulations_search" + toolRegulationEvidence = "regulation_evidence" +) + +func registerRegulationsTools(reg *tools.Registry, caps hostcaps) error { + if err := reg.Register(core.ToolDesc{ + Name: toolRegulationsSearch, + Description: "检索已发布的医疗器械法规、标准(GB/YY/YY/T 等)、指导原则、通告等共享法规知识库;用户提到标准号、法规名称、条款或监管要求时优先调用本工具,再考虑网络搜索;命中后可用 regulation_evidence 取条款原文以精确引用", + Source: core.SourceBuiltin, ReadOnly: true, + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"query":{"type":"string","description":"检索词"},` + + `"docNo":{"type":"string","description":"标准号/文号,如 GB 9706.1-2020"},` + + `"clauseNo":{"type":"string","description":"条款号,如 7.2.1"},` + + `"kinds":{"type":"array","items":{"type":"string"},"description":"文献类别过滤"},` + + `"currentOnly":{"type":"boolean","description":"仅现行有效版本"},` + + `"limit":{"type":"integer","minimum":1,"maximum":10,"description":"返回条数,默认 5"}},` + + `"required":["query"]}`), + }, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + return regulationsSearchTool(caps, rc, args) + }); err != nil { + return err + } + return reg.Register(core.ToolDesc{ + Name: toolRegulationEvidence, + Description: "按 regulations_search 返回的 corpusRevision 与 nodeId 取条款原文区间与哈希,用于精确引用", + Source: core.SourceBuiltin, ReadOnly: true, + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"corpusRevision":{"type":"string","description":"regulations_search 返回的语料修订号"},` + + `"nodeId":{"type":"string","description":"regulations_search 命中的 nodeId"},` + + `"start":{"type":"integer","description":"原文区间起点(可选)"},` + + `"end":{"type":"integer","description":"原文区间终点(可选)"}},` + + `"required":["corpusRevision","nodeId"]}`), + }, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + return regulationEvidenceTool(caps, rc, args) + }) +} + +type regulationsToolHit struct { + RegulationID string `json:"regulationId"` + EditionID string `json:"editionId"` + NodeID string `json:"nodeId"` + DocNo string `json:"docNo,omitempty"` + Title string `json:"title,omitempty"` + EditionLabel string `json:"editionLabel,omitempty"` + LegalStatus string `json:"legalStatus,omitempty"` + ClauseNo string `json:"clauseNo,omitempty"` + HeadingPath []string `json:"headingPath,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + Disclosure string `json:"disclosure"` + WithheldReason string `json:"withheldReason,omitempty"` + PageStart int `json:"pageStart,omitempty"` + PageEnd int `json:"pageEnd,omitempty"` + IsSummary bool `json:"isSummary,omitempty"` + MatchKinds []string `json:"matchKinds,omitempty"` +} + +type regulationsToolSearchOutput struct { + CorpusRevision string `json:"corpusRevision"` + State string `json:"state"` + DegradedReasons []string `json:"degradedReasons,omitempty"` + Hits []regulationsToolHit `json:"hits"` +} + +type regulationsToolUnavailable struct { + State string `json:"state"` + Reason string `json:"reason"` +} + +func regulationsSearchTool(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + var a struct { + Query string `json:"query"` + DocNo string `json:"docNo"` + ClauseNo string `json:"clauseNo"` + Kinds []string `json:"kinds"` + CurrentOnly bool `json:"currentOnly"` + Limit int `json:"limit"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("%s: bad args: %w", toolRegulationsSearch, err) + } + } + a.Query = strings.TrimSpace(a.Query) + if a.Query == "" { + return "", fmt.Errorf("%s: query is required", toolRegulationsSearch) + } + limit := a.Limit + if limit <= 0 { + limit = regulationsSearchDefaultLimit + } else if limit > regulationsSearchMaxLimit { + limit = regulationsSearchMaxLimit + } + h, err := regulationsToolHost(caps, rc, toolRegulationsSearch) + if err != nil { + return "", err + } + ctx, cancel := regulationsToolContext(rc) + defer cancel() + res, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{ + SchemaVersion: extension.RegulationsSchemaVersion, + Query: a.Query, + Kinds: a.Kinds, + CurrentOnly: a.CurrentOnly, + DocNo: strings.TrimSpace(a.DocNo), + ClauseNo: strings.TrimSpace(a.ClauseNo), + Limit: limit, + }) + if err != nil { + return regulationsToolError(toolRegulationsSearch, err) + } + out := regulationsToolSearchOutput{CorpusRevision: res.CorpusRevision, State: res.State, + DegradedReasons: res.DegradedReasons, Hits: make([]regulationsToolHit, 0, len(res.Hits))} + for _, hit := range res.Hits { + out.Hits = append(out.Hits, regulationsToolHit{ + RegulationID: hit.RegulationID, EditionID: hit.EditionID, NodeID: hit.NodeID, + DocNo: hit.DocNo, Title: hit.Title, EditionLabel: hit.EditionLabel, LegalStatus: hit.LegalStatus, + ClauseNo: hit.ClauseNo, HeadingPath: hit.HeadingPath, Excerpt: hit.Excerpt, + Disclosure: hit.Disclosure, WithheldReason: hit.WithheldReason, + PageStart: hit.PageStart, PageEnd: hit.PageEnd, IsSummary: hit.IsSummary, MatchKinds: hit.MatchKinds, + }) + } + return regulationsToolJSON(out) +} + +func regulationEvidenceTool(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + var a struct { + CorpusRevision string `json:"corpusRevision"` + NodeID string `json:"nodeId"` + Start int `json:"start"` + End int `json:"end"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("%s: bad args: %w", toolRegulationEvidence, err) + } + } + a.CorpusRevision, a.NodeID = strings.TrimSpace(a.CorpusRevision), strings.TrimSpace(a.NodeID) + if a.CorpusRevision == "" || a.NodeID == "" { + return "", fmt.Errorf("%s: corpusRevision and nodeId are required", toolRegulationEvidence) + } + h, err := regulationsToolHost(caps, rc, toolRegulationEvidence) + if err != nil { + return "", err + } + ctx, cancel := regulationsToolContext(rc) + defer cancel() + ev, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{ + SchemaVersion: extension.RegulationsSchemaVersion, + CorpusRevision: a.CorpusRevision, + NodeID: a.NodeID, + Start: a.Start, + End: a.End, + }) + if err != nil { + return regulationsToolError(toolRegulationEvidence, err) + } + return regulationsToolJSON(ev) +} + +// regulationsToolHost binds the audited host to the run owner, who is both the +// owner and the actor of an agent tool call. +func regulationsToolHost(caps hostcaps, rc tools.ToolExecCtx, name string) (inprocHost, error) { + owner := rc.OwnerID() + if owner == "" { + return inprocHost{}, fmt.Errorf("%s: run owner is required", name) + } + return inprocHost{caps: caps, pluginID: regulationsToolCallerID, owner: owner, actor: owner}, nil +} + +func regulationsToolContext(rc tools.ToolExecCtx) (context.Context, context.CancelFunc) { + parent := context.Background() + if c, ok := rc.(interface{ Context() context.Context }); ok && c.Context() != nil { + parent = c.Context() + } + return context.WithTimeout(parent, regulationsToolTimeout) +} + +func regulationsToolError(name string, err error) (string, error) { + if errors.Is(err, extension.ErrRegulationsUnavailable) { + return regulationsToolJSON(regulationsToolUnavailable{State: extension.RegulationsStateUnavailable, + Reason: "shared regulations knowledge base is not available"}) + } + return "", fmt.Errorf("%s: %w", name, err) +} + +func regulationsToolJSON(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/app/regulations_tools_test.go b/internal/app/regulations_tools_test.go new file mode 100644 index 0000000..3f2db92 --- /dev/null +++ b/internal/app/regulations_tools_test.go @@ -0,0 +1,226 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "agent-system-backend/internal/tools" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +type regulationsToolCtx struct{ owner string } + +func (c regulationsToolCtx) ProjectID() string { return "" } +func (c regulationsToolCtx) ConversationID() string { return "" } +func (c regulationsToolCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { + return nil, nil +} +func (c regulationsToolCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) { + return core.Artifact{}, errors.New("read-only") +} +func (c regulationsToolCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil } +func (c regulationsToolCtx) PeerConversationMessages(string, int) ([]core.Message, error) { + return nil, nil +} +func (c regulationsToolCtx) OwnerID() string { return c.owner } +func (c regulationsToolCtx) WorkspaceRoot() string { return "" } +func (c regulationsToolCtx) SettingBool(_ string, d bool) bool { return d } + +type recordingRegulationsProvider struct { + *fakeRegulationsProvider + searches []extension.RegulationsSearchRequest + evidences []extension.RegulationEvidenceRequest +} + +func (p *recordingRegulationsProvider) SearchRegulations(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + p.searches = append(p.searches, req) + return p.fakeRegulationsProvider.SearchRegulations(ctx, c, req) +} + +func (p *recordingRegulationsProvider) ResolveRegulationEvidence(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + p.evidences = append(p.evidences, req) + return p.fakeRegulationsProvider.ResolveRegulationEvidence(ctx, c, req) +} + +func newRegulationsToolsRegistry(t *testing.T, h inprocHost) *tools.Registry { + t.Helper() + reg := tools.NewRegistry() + if err := registerRegulationsTools(reg, h.caps); err != nil { + t.Fatal(err) + } + return reg +} + +func registerRecordingRegulations(t *testing.T, h inprocHost) *recordingRegulationsProvider { + t.Helper() + p := &recordingRegulationsProvider{fakeRegulationsProvider: &fakeRegulationsProvider{}} + if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil { + t.Fatal(err) + } + return p +} + +func callRegulationsTool(reg *tools.Registry, name, args string) core.ToolCallResult { + return reg.Execute(regulationsToolCtx{owner: "alice"}, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)}) +} + +func TestRegulationsToolsRegisteredAsReadOnlyBuiltins(t *testing.T) { + h, _ := newRegulationsTestHost(t) + reg := newRegulationsToolsRegistry(t, h) + for _, name := range []string{toolRegulationsSearch, toolRegulationEvidence} { + desc, _, ok := reg.Get(name) + if !ok || desc.Source != core.SourceBuiltin || !desc.ReadOnly || desc.Edits || desc.Hidden { + t.Fatalf("%s desc = %+v ok=%v", name, desc, ok) + } + var schema struct { + Type string `json:"type"` + Required []string `json:"required"` + Props map[string]any `json:"properties"` + } + if err := json.Unmarshal(desc.Parameters, &schema); err != nil || schema.Type != "object" || len(schema.Required) == 0 { + t.Fatalf("%s schema = %s %v", name, desc.Parameters, err) + } + for _, req := range schema.Required { + if _, ok := schema.Props[req]; !ok { + t.Fatalf("%s required %q not declared", name, req) + } + } + } +} + +func TestRegulationsSearchToolMapsHitsAndClampsLimit(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded, + DegradedReasons: []string{"vector_index_unavailable"}, + Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020", + Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1", + HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段", ExcerptStart: 3, NodeSHA256: "abc", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13, + IsSummary: true, MatchKinds: []string{"docNo", "text"}, Score: 0.9}}} + reg := newRegulationsToolsRegistry(t, h) + res := callRegulationsTool(reg, toolRegulationsSearch, + `{"query":" 电气安全 ","docNo":"GB 9706.1-2020","clauseNo":"7.2.1","kinds":["standard"],"currentOnly":true,"limit":50}`) + if res.Status != "ok" { + t.Fatalf("search result = %+v", res) + } + want := regulationsToolSearchOutput{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded, + DegradedReasons: []string{"vector_index_unavailable"}, + Hits: []regulationsToolHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020", + Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1", + HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13, + IsSummary: true, MatchKinds: []string{"docNo", "text"}}}} + var got regulationsToolSearchOutput + if err := json.Unmarshal([]byte(res.Output), &got); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("search output = %s %v", res.Output, err) + } + if strings.Contains(res.Output, "score") || strings.Contains(res.Output, "nodeSha256") { + t.Fatalf("search output leaks unlisted fields: %s", res.Output) + } + wantReq := extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "电气安全", Kinds: []string{"standard"}, + CurrentOnly: true, DocNo: "GB 9706.1-2020", ClauseNo: "7.2.1", Limit: regulationsSearchMaxLimit} + if len(p.searches) != 1 || !reflect.DeepEqual(p.searches[0], wantReq) { + t.Fatalf("provider request = %+v", p.searches) + } + wantCaller := extension.RegulationsCaller{PluginID: regulationsToolCallerID, Actor: "alice", Owner: "alice"} + if len(p.callers) != 1 || p.callers[0] != wantCaller { + t.Fatalf("caller = %+v", p.callers) + } + callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`) + callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":-3}`) + callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":7}`) + for i, want := range []int{regulationsSearchDefaultLimit, regulationsSearchDefaultLimit, 7} { + if got := p.searches[i+1].Limit; got != want { + t.Fatalf("limit case %d = %d, want %d", i, got, want) + } + } + evs, err := st.ListAuditEvents("alice", 10) + if err != nil || len(evs) != 4 { + t.Fatalf("audit events = %+v %v", evs, err) + } + for _, ev := range evs { + if ev.Action != auditRegulationsSearch || ev.Actor != "plugin:"+regulationsToolCallerID || strings.Contains(ev.Evidence, "电气安全") { + t.Fatalf("search audit = %+v", ev) + } + } +} + +func TestRegulationEvidenceToolMapsFields(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-3", RegulationID: "r1", EditionID: "e1", NodeID: "n1", + ClauseNo: "7.2.1", Excerpt: "原文", Start: 4, End: 10, NodeSHA256: "abc", SourceAssetSHA256: "def", + PageStart: 12, PageEnd: 12, Disclosure: extension.RegulationDisclosureFull, HashVerified: true} + reg := newRegulationsToolsRegistry(t, h) + res := callRegulationsTool(reg, toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1","start":4,"end":10}`) + if res.Status != "ok" { + t.Fatalf("evidence result = %+v", res) + } + var got extension.RegulationEvidence + if err := json.Unmarshal([]byte(res.Output), &got); err != nil || got != p.evidence { + t.Fatalf("evidence output = %s %v", res.Output, err) + } + wantReq := extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-3", NodeID: "n1", Start: 4, End: 10} + if len(p.evidences) != 1 || p.evidences[0] != wantReq { + t.Fatalf("provider request = %+v", p.evidences) + } + if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 1 || evs[0].Action != auditRegulationsEvidence { + t.Fatalf("evidence audit = %+v %v", evs, err) + } +} + +func TestRegulationsToolsRejectMissingArgs(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + reg := newRegulationsToolsRegistry(t, h) + for _, c := range []struct{ name, args string }{ + {toolRegulationsSearch, `{}`}, + {toolRegulationsSearch, `{"query":" "}`}, + {toolRegulationEvidence, `{"corpusRevision":"rev-3"}`}, + {toolRegulationEvidence, `{"nodeId":"n1"}`}, + {toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":" "}`}, + } { + if res := callRegulationsTool(reg, c.name, c.args); res.Status != "error" { + t.Fatalf("%s %s accepted: %+v", c.name, c.args, res) + } + } + if len(p.callers) != 0 { + t.Fatal("provider reached with missing arguments") + } +} + +func TestRegulationsToolsReportUnavailable(t *testing.T) { + h, st := newRegulationsTestHost(t) + reg := newRegulationsToolsRegistry(t, h) + assertUnavailable := func(label string) { + t.Helper() + for _, c := range []struct{ name, args string }{ + {toolRegulationsSearch, `{"query":"q"}`}, + {toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1"}`}, + } { + res := callRegulationsTool(reg, c.name, c.args) + var out regulationsToolUnavailable + if res.Status != "ok" || json.Unmarshal([]byte(res.Output), &out) != nil || + out.State != extension.RegulationsStateUnavailable || out.Reason == "" { + t.Fatalf("%s %s = %+v", label, c.name, res) + } + } + } + assertUnavailable("no provider") + if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 { + t.Fatalf("unavailable calls audited: %+v %v", evs, err) + } + p := registerRecordingRegulations(t, h) + p.err = extension.ErrRegulationsUnavailable + assertUnavailable("provider unavailable") + p.err = errors.New("provider failure") + if res := callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`); res.Status != "error" { + t.Fatalf("provider failure reported as success: %+v", res) + } +} diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index a0f3ad7..cb1e9a7 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -26,7 +26,7 @@ const materialSubagentID = "agent.sub.material" func fallbackSubagentSpec() prompts.SubagentSpec { return prompts.SubagentSpec{ - Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"}, + Tools: []string{"knowledge_search", "regulations_search", "regulation_evidence", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"}, Model: "lightweight", } }