feat(harness): add rag and log projections

This commit is contained in:
zhuyongxin
2026-07-21 20:12:18 +08:00
parent 0dbdd7d8d3
commit 3e602781d6
20 changed files with 1140 additions and 0 deletions
+1
View File
@@ -37,3 +37,4 @@
| 2026-06-24 | lookup-knowledge-integration | 接入知识库检索,支持 L0 精确匹配和 L1 语义检索。 | 知识库检索 | L0精确匹配, L1语义检索, frontmatter, 混合检索 | - | archived | | 2026-06-24 | lookup-knowledge-integration | 接入知识库检索,支持 L0 精确匹配和 L1 语义检索。 | 知识库检索 | L0精确匹配, L1语义检索, frontmatter, 混合检索 | - | archived |
| 2026-06-23 | phase1-infrastructure | 搭建第一阶段基础设施,包括 MySQL、Redis、Milvus、Flyway 和 JPA。 | 基础设施/文档管理 | MySQL, Redis, Milvus, Flyway, JPA, 向量检索, 类别过滤 | - | archived | | 2026-06-23 | phase1-infrastructure | 搭建第一阶段基础设施,包括 MySQL、Redis、Milvus、Flyway 和 JPA。 | 基础设施/文档管理 | MySQL, Redis, Milvus, Flyway, JPA, 向量检索, 类别过滤 | - | archived |
| 2026-05-29 | chatmodel-abstraction | 抽象 ChatModel 和 EmbeddingModel,支持多模型路由。 | 解耦/多模型路由 | ChatModel, EmbeddingModel, DeepSeek, BGE-M3, SiliconFlow, Spring AI | - | archived | | 2026-05-29 | chatmodel-abstraction | 抽象 ChatModel 和 EmbeddingModel,支持多模型路由。 | 解耦/多模型路由 | ChatModel, EmbeddingModel, DeepSeek, BGE-M3, SiliconFlow, Spring AI | - | archived |
| 2026-07-21 | single-react-rag-log-projections | RAG/log projection adapters through ToolBoundary | Harness/Tool projection | ISS-014, RAG, query_logs, projection, scope, redaction, MOCK, NO_EVIDENCE | openspec/changes/archive/2026-07-21-single-react-rag-log-projections | archived |
@@ -0,0 +1,25 @@
# Acceptance: single-react-rag-log-projections
## Commit preflight
- OpenSpec strict validation: passed.
- Scope: RAG and Mock query-log projectors/adapters through the existing ToolBoundary.
- Explicit non-goals: real CLS/MCP, MySQL, Diagnosis Agent cutover, public Chat/AIOps/SSE changes, legacy recorder cleanup.
- Main residual risk: legacy log payloads contain sensitive values; projector tests must prove redaction before Agent serialization.
## Apply acceptance
- Implemented and verified.
- Static verification and focused/regression Maven tests are recorded in `evidence.md`.
- No browser/manual verification applies; this stage adds no UI or public protocol change.
- Residual risks: live Redis/CLS integration and Agent cutover remain later stages.
## Archive acceptance
- `.archive-ready` marker is created after all tasks and checks pass.
- OpenSpec is ready to move to the dated archive directory.
- Archive completed at `openspec/changes/archive/2026-07-21-single-react-rag-log-projections`.
## Remaining work
- Real Redis/CLS integration, MySQL projection, Diagnosis Agent cutover and final E2E remain later stages.
@@ -0,0 +1,24 @@
# Brief: single-react-rag-log-projections
## Background
阶段 3A 已经统一 ToolBoundary 和 canonical invocation store。下一阶段需要把现有 RAG 与 Mock 日志工具接入该边界,并把旧工具输出投影为冻结的 Agent-facing ACI 结果。
## Goal
- 提供 bounded RAG evidence projection。
- 提供带完整 logical scope 和 Mock provenance 的 query-log projection。
- 复用阶段 3A 生命周期、Run ownership、tool_call_id、预算、错误和 canonical record。
## Non-goals
- 不接入真实 CLS/MCP。
- 不实现 MySQL projection。
- 不切换 Diagnosis Agent、Chat/AIOps、SSE 或旧 recorder。
## Classification
- Scale: complex
- Interface impact: L2 internal Harness adapter/projector
- Issue: ISS-014 stage 3B
- Change slug: `single-react-rag-log-projections`
@@ -0,0 +1,72 @@
# Decisions: single-react-rag-log-projections
## Discover status
- Checkpoint: Discover
- Capability source: `sm-flow` with `grill-with-docs` codebase evidence; no external service integration required.
- Scale: complex, because two tool adapters share a lifecycle boundary and define bounded Agent-facing output semantics.
## Evidence-driven findings
1. `ToolBoundary` currently accepts `project(String rawResponse)` and already owns Run/ID/authorization/read-only/JSON/budget/lifecycle enforcement.
2. `LookupKnowledgeTool` emits `evidenceBlocks`, `contextPack`, `retrievalTrace`, `rerankTrace`, session domains, and message; these are internal retrieval/audit fields and must not be projected.
3. `QueryLogsTools` emits region, physical log topic, result limit, instance and metrics; the frozen contract requires logical topic/query/lookback scope and `source_kind=MOCK` instead.
4. Existing ACI records already define the required snake_case fields and immutable collections.
5. The request scope must be passed to the log projector through a typed adapter method rather than inferred from raw output.
## User-confirmed direction
- Use the framework-provided `tool_call_id` only.
- Keep lifecycle status and evidence status separate.
- Implement the stage in phases and complete sm-flow archive plus Git commit before the next stage.
- Adopt the request-aware projector adapter for log scope preservation.
## Question pool
| Dimension | Question | Mode | Conclusion | Status |
|---|---|---|---|---|
| Terminology | Are RAG traces and context packs Agent evidence? | evidence-driven | No. They are internal retrieval/audit details and are excluded from projection. | resolved |
| Boundary | How is log scope preserved when the generic projector has no request? | evidence-driven | Typed adapter carries `QueryLogsRequest` into a request-aware projector method. | resolved |
| Provenance | Which log source is implemented now? | evidence-driven | Existing Mock source only; result always records `source_kind=MOCK`. | resolved |
| Negative result | What does an empty query mean? | evidence-driven | `NO_EVIDENCE` for the recorded scope, with no health/problem inference. | resolved |
| Compatibility | Should legacy tools and public paths be changed now? | evidence-driven | No. Add adapters/projectors only; cutover is later. | resolved |
## Risks
- Existing mock messages contain hostnames, pod IDs, SQL literals, and stack-like text; sanitization must happen before projection.
- Collection limits and excerpt limits can make the Agent result incomplete; `truncated` must be explicit.
- The generic boundary API should remain reusable for stage 3C, so request-aware behavior belongs in an adapter or specialized projector interface.
## Discover checkpoint
- Proposal created: `openspec/changes/single-react-rag-log-projections/proposal.md`
- Context and issue evidence recorded.
- No unresolved user-interview question remains for this bounded stage; implementation direction was explicitly accepted in the conversation.
## Commit audit
- Capability source: `sm-flow` and local OpenSpec CLI.
- OpenSpec strict validation: passed for `single-react-rag-log-projections`.
- Cross-artifact alignment:
- brief goals/non-goals -> proposal scope: aligned.
- proposal boundaries and request-aware projector decision -> design: aligned.
- design projection bounds, redaction, scope and adapter ownership -> spec requirements: aligned.
- spec scenarios -> tasks for limits, RAG, logs, boundary integration and verification: aligned.
- Interface impact: L2 internal Harness adapter/projector only; no public protocol or legacy runtime cutover.
- Preflight risks accepted: legacy payload drift fails closed; sensitive log fields are redacted; total projection budget is explicit.
## Commit gate
- [x] proposal, design, specs and tasks exist.
- [x] strict OpenSpec validation passes.
- [x] all evidence-driven questions are resolved.
- [x] no unresolved interface decision remains.
- [x] `.committed` marker created for Apply.
## Archive result
- Apply tasks complete.
- `.archive-ready` marker created.
- OpenSpec archived at `openspec/changes/archive/2026-07-21-single-react-rag-log-projections`.
- Main capability specification added at `openspec/specs/rag-log-projections/spec.md`.
- Next stage remains 3C MySQL projection; no Agent cutover is implied by this archive.
@@ -0,0 +1,25 @@
# Evidence: single-react-rag-log-projections
## Static verification
- `git diff --check`: passed.
- Existing legacy files were checked and have no diff: `LookupKnowledgeTool`, `QueryLogsTools`, `ChatService`, `AiOpsService`, and `ToolInvocationRecorder`.
- OpenSpec strict validation: passed for `single-react-rag-log-projections`.
## Script/build verification
- `mvn -q -DskipTests compile`: passed.
- Focused projection tests: passed (`RagResultProjectorTest`, `QueryLogsResultProjectorTest`).
- Adapter tests: passed (`ToolAdapterTest`).
- Stage regression suite: passed (`CanonicalInvocationStoreTest`, `ToolBoundaryTest`, ACI/Core/retry/key/config/ChatController tests plus the new projection tests).
## Coverage
- RAG: bounded exact excerpts, duplicate document IDs, internal field exclusion, `NO_EVIDENCE`, truncation and framework ID.
- Logs: logical scope, Mock provenance, pattern aggregation, timeline sampling, sensitive value redaction, empty result and bounded output.
- Boundary: adapters use the existing canonical lifecycle and do not return raw payloads.
## Not verified in this stage
- Real Redis connectivity and live CLS/MCP integration.
- Diagnosis Agent/application cutover and end-to-end Maven runtime flow.
@@ -0,0 +1,69 @@
# Design: single-react-rag-log-projections
## Architecture
```text
typed ACI request + framework envelope
|
v
RagToolAdapter / QueryLogsToolAdapter
| (legacy executor is internal and request controls are injected here)
v
ToolBoundary.execute(..., raw -> requestAwareProjector.project(...))
|
+--> CanonicalInvocationStore (complete raw + bounded agent result)
+--> ToolBoundaryResult (bounded result only)
```
The adapters are the only place that knows how the current legacy tools are called. They parse the typed ACI request before invoking the boundary, serialize the legacy result as raw JSON, and bind the typed request and framework `tool_call_id` to the projector closure. `ToolBoundary` remains responsible for all lifecycle, ownership, budget, size and error rules.
## Request-aware projection decision
The generic `ToolResultProjector.project(String rawResponse)` remains unchanged for stage 3A compatibility. Stage 3B introduces specialized projector methods:
```java
ProjectedToolResult project(RagToolRequest request, String toolCallId, String rawResponse);
ProjectedToolResult project(QueryLogsRequest request, String toolCallId,
LogQueryScope scope, String rawResponse);
```
Adapters pass these methods through a lambda to the generic boundary. This preserves request scope without adding request fields to `ProjectedToolResult`, changing the generic boundary API, or relying on raw JSON conventions.
## RAG projection
- Parse only `evidenceBlocks` from the legacy response.
- Accept an evidence block only when its content/excerpt is non-blank.
- Use `source` as the stable `document_id`; fall back to title, then a deterministic ordinal only for malformed legacy records.
- Deduplicate by document ID while retaining the first exact excerpt.
- Project only `document_id`, `source`, `title`, `breadcrumb`, and bounded `excerpt`.
- Ignore `contextPack`, `retrievalTrace`, `rerankTrace`, scores, hit reasons, domains, and messages.
- Set `NO_EVIDENCE` when no usable excerpt remains.
## Query-log projection
- Parse only the legacy `logs` array and success/total fields needed for semantics.
- Map `LogTopic` to the existing Mock topic names inside the adapter (`APPLICATION -> application-logs`, `DATABASE_SLOW_QUERY -> database-slow-query`, `SYSTEM_EVENTS -> system-events`).
- Inject the legacy default region and an internal source limit; neither is part of the ACI request or result.
- Build `LogQueryScope` from the logical request and `lookback_minutes`, using the adapter clock for `end_time`.
- Always record `source_kind=MOCK` for this stage.
- Exclude `instance` and `metrics`; redact credentials, host/pod identifiers, PIDs, IPs, SQL literals and stack-like suffixes from messages.
- Aggregate patterns by sanitized level, service and normalized message, with count and first/last timestamps.
- Sample timeline events deterministically when the source exceeds the event bound.
- Set `NO_EVIDENCE` for a successful empty `logs` array; legacy error responses become projection errors.
## Bounds
`ToolProjectionLimits` defines maximum evidence/items, excerpt/message characters, patterns, events and total Agent projection UTF-8 bytes. Collection bounds set `truncated=true`. If the serialized projection still exceeds the total budget, the projector removes the last timeline/pattern/evidence items until it fits; if no valid bounded result can be produced, it throws and the boundary records `PROJECTION_ERROR`.
## Compatibility and ownership
- No changes to `LookupKnowledgeTool`, `QueryLogsTools`, `ToolInvocationRecorder`, JPA entities, ChatService, AiOpsService, Controller or public HTTP/SSE payloads.
- Redis access remains in `RedisCanonicalInvocationStore`.
- Legacy tools remain the source of raw data until a later Diagnosis Agent cutover.
## Risks and mitigations
- Legacy output shape drift: strict JSON parsing and focused malformed-response tests fail closed.
- Redaction can remove useful details: only sensitive tokens/identifiers are replaced; service, level and sanitized message context remain.
- Scope clock skew: adapter uses an injected `Clock` and tests use a fixed clock.
- Projection size pressure: deterministic collection bounds and explicit `truncated` prevent silent overflow.
@@ -0,0 +1,51 @@
# Proposal: single-react-rag-log-projections
## Problem
阶段 3A 已提供统一 `ToolBoundary` 和 canonical invocation store,但 RAG 与 `query_logs` 仍只有旧工具输出。旧输出包含检索轨迹、上下文打包、基础设施字段、实例信息和未受约束的日志内容,不能直接作为单体 Diagnosis Agent 的 ACI evidence projection。若分别实现状态机或存储,会重新引入重复的生命周期、Run ownership 和错误处理逻辑。
## Proposed change
- 新增 RAG result projector,将旧 `LookupKnowledgeTool` JSON 转换为冻结的 `RagToolResult`。
- 新增 query-log result projector,将现有 Mock `QueryLogsTools` JSON 转换为冻结的 `QueryLogsToolResult`。
- projector 负责精确摘录、文档去重、证据/事件/模式数量上限、整体字符预算、时间线抽样和脱敏。
- projector 由请求感知适配层调用,使 `topic`、`query` 和 `lookback_minutes` 保留在完整 `scope` 中;不扩展 Agent request,不让 raw response 直接进入 Agent。
- 两个适配器均通过阶段 3A `ToolBoundary` 执行,沿用框架 `tool_call_id`、RunContext、canonical store、`PROJECTING/READY/ERROR` 与 `EVIDENCE_FOUND/NO_EVIDENCE/ERROR` 语义。
- 保留旧工具、旧 recorder、Chat/AIOps、Controller 和公开协议不变;本阶段只新增投影/适配层与 focused tests。
## Scope
### In scope
- RAG projector and adapter.
- Query-log projector and adapter for existing Mock source.
- Request-aware projection entry point required to preserve log scope.
- Contract, sanitization, deduplication, truncation, no-evidence and boundary integration tests.
### Out of scope
- Real CLS/MCP log integration.
- MySQL projector (stage 3C).
- Diagnosis Agent cutover, Guards, Chat use-case cutover, SSE changes, or legacy recorder cleanup.
- Changes to the frozen ACI DTO field names.
## Constraints and risks
- Raw payload remains Harness-only canonical data and is never returned to the Agent.
- `NO_EVIDENCE` is scoped to the recorded query and time window; it is not a health claim.
- Log messages may contain credentials, hostnames, pod IDs, SQL literals, or stack traces; projection must redact these before Agent exposure.
- A projector must not silently truncate raw data. It may bound Agent-facing collections and excerpts while setting `truncated=true`.
- The existing generic `ToolResultProjector` API has no request argument. The adapter will carry the typed request alongside the projector invocation, keeping the generic boundary reusable and avoiding a raw JSON scope convention.
## Acceptance direction
- Serialized RAG output contains only the frozen fields and bounded exact excerpts.
- Serialized log output contains complete logical scope, `source_kind=MOCK`, bounded patterns/events, distinct match and returned counts, and no infrastructure controls.
- Both projectors produce `NO_EVIDENCE` for successful empty results and `ERROR` for malformed/unsafe input.
- Boundary tests prove framework ID preservation, canonical lifecycle reuse, raw isolation, and safe errors.
## Context sources
- ISS-014 stage 3B requirements.
- `aci-evidence-tool-contracts` and `canonical-tool-invocation-store` specifications.
- Existing `LookupKnowledgeTool`, `QueryLogsTools`, and frozen contract tests.
@@ -0,0 +1,73 @@
# rag-log-projections Specification
## Purpose
Define bounded RAG and Mock query-log projections that execute through the stage 3A ToolBoundary and expose only the frozen ACI contracts to the Agent.
## ADDED Requirements
### Requirement: RAG projection SHALL expose bounded document evidence only
The RAG adapter SHALL accept the logical `query` request, execute the existing knowledge tool through ToolBoundary, and project only `RagToolResult` fields. Context packs, retrieval traces, rerank traces, scores, hit reasons, domains, messages and full document bodies SHALL NOT appear in the Agent result.
#### Scenario: RAG evidence is projected
- WHEN the legacy response contains usable evidence blocks
- THEN the result contains the original query, framework `tool_call_id`, deduplicated evidence with exact bounded excerpts, a returned count, and an explicit truncation flag
#### Scenario: RAG result has no usable excerpt
- WHEN the legacy response has no usable evidence block
- THEN the boundary returns `READY` with `evidence_status=NO_EVIDENCE`, the original query and an empty evidence list
#### Scenario: RAG duplicate documents
- WHEN multiple blocks identify the same source/document
- THEN only the first document is returned and the result remains deterministic
### Requirement: Query-log projection SHALL preserve logical scope and Mock provenance
The query-log adapter SHALL accept only logical topic, query and optional lookback minutes, execute the existing Mock source through ToolBoundary, and project `source_kind=MOCK`, complete scope, match count, returned count, bounded patterns, bounded timeline events and truncation.
#### Scenario: Mock logs are projected
- WHEN the legacy Mock response contains log entries
- THEN the result includes the logical topic/query/time window, sanitized pattern aggregates, sanitized timeline events, distinct match and returned counts, and `source_kind=MOCK`
#### Scenario: Empty Mock result
- WHEN the legacy Mock response is successful with an empty log array
- THEN the boundary returns `READY` with `evidence_status=NO_EVIDENCE` and preserves the full query scope
#### Scenario: Legacy log error
- WHEN the legacy response indicates failure or is malformed
- THEN the boundary returns `ERROR` with a bounded projection error and no Agent result
### Requirement: Projection SHALL redact and bound sensitive log content
The log projector SHALL exclude instance and metrics fields and redact credentials, token-like values, host/pod identifiers, PIDs, IP addresses, SQL literals and stack-like suffixes from Agent-facing messages. It SHALL enforce per-item, collection and total UTF-8 bounds and set `truncated=true` when any bound removes data.
#### Scenario: Sensitive fields are present
- WHEN a log entry includes instance, metrics, a secret assignment, a pod identifier or a SQL literal
- THEN none of those raw values are present in the projected Agent result
#### Scenario: Projection exceeds collection budget
- WHEN evidence, patterns, events or excerpts exceed configured bounds
- THEN the result is valid JSON, contains only bounded collections, and sets `truncated=true`
### Requirement: Adapters SHALL reuse canonical boundary ownership
Both adapters SHALL pass the framework `tool_call_id` and RunContext to the existing ToolBoundary and SHALL NOT create a second ID, write a parallel store, return raw responses, or modify legacy audit paths.
#### Scenario: Framework ID and Run are valid
- WHEN an adapter executes a valid request
- THEN the canonical record and bounded result use the exact framework ID and the current Run ID
#### Scenario: Boundary rejects the request
- WHEN Run ownership, authorization, read-only, duplicate, budget or size preflight fails
- THEN the adapter returns the boundary's safe error without invoking the legacy tool or projector
@@ -0,0 +1,24 @@
# Tasks: single-react-rag-log-projections
## 1. Projection limits and request-aware interfaces
- [x] 1.1 Add `ToolProjectionLimits` with explicit item, excerpt/message, collection and total UTF-8 limits.
- [x] 1.2 Add request-aware projector methods and adapter-facing typed execution helpers without changing the generic ToolBoundary API.
## 2. RAG projection and adapter
- [x] 2.1 Implement JSON parsing, usable excerpt filtering, source/document ID fallback, deduplication, exact excerpt bounding and `RagToolResult` serialization.
- [x] 2.2 Implement the RAG adapter that maps typed request JSON to the legacy knowledge tool and invokes ToolBoundary with the framework ID.
- [x] 2.3 Add tests for field exclusion, no evidence, duplicate documents, excerpt/total bounds, malformed raw response and canonical ID preservation.
## 3. Query-log projection and adapter
- [x] 3.1 Implement logical topic mapping, injected legacy Mock invocation, lookback scope construction and `source_kind=MOCK`.
- [x] 3.2 Implement redaction, pattern aggregation, deterministic timeline sampling, counts and bounded serialization.
- [x] 3.3 Add tests for scope, provenance, pattern/timeline bounds, redaction, empty result, malformed/error response and no topic discovery call.
## 4. Boundary integration and verification
- [x] 4.1 Route both adapters through the existing ToolBoundary and verify raw data remains canonical-only.
- [x] 4.2 Run focused projector/adapter/boundary tests and the prior stage regression suite.
- [x] 4.3 Validate OpenSpec, update devflow acceptance/evidence, archive the change and commit before stage 3C.
@@ -0,0 +1,23 @@
# rag-log-projections Specification
## Purpose
Define bounded RAG and Mock query-log projections that execute through the stage 3A ToolBoundary and expose only the frozen ACI contracts to the Agent.
## Requirements
### Requirement: RAG projection SHALL expose bounded document evidence only
The RAG adapter SHALL accept the logical `query` request, execute the existing knowledge tool through ToolBoundary, and project only `RagToolResult` fields. Context packs, retrieval traces, rerank traces, scores, hit reasons, domains, messages and full document bodies SHALL NOT appear in the Agent result.
### Requirement: Query-log projection SHALL preserve logical scope and Mock provenance
The query-log adapter SHALL accept only logical topic, query and optional lookback minutes, execute the existing Mock source through ToolBoundary, and project `source_kind=MOCK`, complete scope, match count, returned count, bounded patterns, bounded timeline events and truncation.
### Requirement: Projection SHALL redact and bound sensitive log content
The log projector SHALL exclude instance and metrics fields and redact credentials, token-like values, host/pod identifiers, PIDs, IP addresses, SQL literals and stack-like suffixes from Agent-facing messages. It SHALL enforce per-item, collection and total UTF-8 bounds and set `truncated=true` when any bound removes data.
### Requirement: Adapters SHALL reuse canonical boundary ownership
Both adapters SHALL pass the framework `tool_call_id` and RunContext to the existing ToolBoundary and SHALL NOT create a second ID, write a parallel store, return raw responses, or modify legacy audit paths.
@@ -0,0 +1,94 @@
package com.superbiz.agent.harness.tool.adapter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryErrorCode;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
import com.superbiz.agent.harness.tool.boundary.ToolCallRequestEnvelope;
import com.superbiz.agent.harness.tool.contract.LogQueryScope;
import com.superbiz.agent.harness.tool.contract.LogTopic;
import com.superbiz.agent.harness.tool.contract.QueryLogsRequest;
import com.superbiz.agent.harness.tool.projection.QueryLogsResultProjector;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
/** Bridges logical query-log requests and the existing Mock tool through ToolBoundary. */
public final class QueryLogsToolAdapter {
@FunctionalInterface
public interface LegacyExecutor {
String execute(String region, String legacyTopic, String query, Integer limit) throws Exception;
}
private static final String DEFAULT_REGION = "ap-guangzhou";
private static final int DEFAULT_LOOKBACK_MINUTES = 30;
private static final int DEFAULT_LEGACY_LIMIT = 100;
private final ToolBoundary boundary;
private final ObjectMapper objectMapper;
private final QueryLogsResultProjector projector;
private final LegacyExecutor legacyExecutor;
private final Clock clock;
private final String region;
private final int legacyLimit;
public QueryLogsToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper,
QueryLogsResultProjector projector, LegacyExecutor legacyExecutor,
Clock clock) {
this(boundary, objectMapper, projector, legacyExecutor, clock, DEFAULT_REGION, DEFAULT_LEGACY_LIMIT);
}
public QueryLogsToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper,
QueryLogsResultProjector projector, LegacyExecutor legacyExecutor,
Clock clock, String region, int legacyLimit) {
this.boundary = Objects.requireNonNull(boundary, "boundary must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.projector = Objects.requireNonNull(projector, "projector must not be null");
this.legacyExecutor = Objects.requireNonNull(legacyExecutor, "legacyExecutor must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
if (region == null || region.isBlank() || legacyLimit <= 0) {
throw new IllegalArgumentException("legacy region and limit must be valid");
}
this.region = region;
this.legacyLimit = legacyLimit;
}
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
try {
QueryLogsRequest request = objectMapper.readValue(envelope.requestJson(), QueryLogsRequest.class);
if (request.topic() == null || request.query() == null || request.query().isBlank()) {
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
}
int lookback = request.lookbackMinutes() == null
? DEFAULT_LOOKBACK_MINUTES : request.lookbackMinutes();
if (lookback <= 0 || lookback > 24 * 60) {
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
}
Instant end = clock.instant();
LogQueryScope scope = new LogQueryScope(
request.topic(), request.query(),
DateTimeFormatter.ISO_INSTANT.format(end.minus(Duration.ofMinutes(lookback))),
DateTimeFormatter.ISO_INSTANT.format(end));
String legacyTopic = legacyTopic(request.topic());
return boundary.execute(context, envelope,
ignored -> legacyExecutor.execute(region, legacyTopic, request.query(), legacyLimit),
raw -> projector.project(request, envelope.toolCallId(), scope, raw));
} catch (Exception e) {
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
ToolBoundaryErrorCode.INVALID_REQUEST);
}
}
private static String legacyTopic(LogTopic topic) {
return switch (topic) {
case APPLICATION -> "application-logs";
case DATABASE_SLOW_QUERY -> "database-slow-query";
case SYSTEM_EVENTS -> "system-events";
};
}
}
@@ -0,0 +1,49 @@
package com.superbiz.agent.harness.tool.adapter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryErrorCode;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
import com.superbiz.agent.harness.tool.boundary.ToolCallRequestEnvelope;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.tool.contract.RagToolRequest;
import com.superbiz.agent.harness.tool.projection.RagResultProjector;
import java.util.Objects;
/** Bridges a typed RAG request and a legacy knowledge executor through ToolBoundary. */
public final class RagToolAdapter {
@FunctionalInterface
public interface LegacyExecutor {
Object execute(String query) throws Exception;
}
private final ToolBoundary boundary;
private final ObjectMapper objectMapper;
private final RagResultProjector projector;
private final LegacyExecutor legacyExecutor;
public RagToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper,
RagResultProjector projector, LegacyExecutor legacyExecutor) {
this.boundary = Objects.requireNonNull(boundary, "boundary must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.projector = Objects.requireNonNull(projector, "projector must not be null");
this.legacyExecutor = Objects.requireNonNull(legacyExecutor, "legacyExecutor must not be null");
}
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
try {
RagToolRequest request = objectMapper.readValue(envelope.requestJson(), RagToolRequest.class);
if (request.query() == null || request.query().isBlank()) {
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
}
return boundary.execute(context, envelope,
ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())),
raw -> projector.project(request, envelope.toolCallId(), raw));
} catch (Exception e) {
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
ToolBoundaryErrorCode.INVALID_REQUEST);
}
}
}
@@ -0,0 +1,208 @@
package com.superbiz.agent.harness.tool.projection;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
import com.superbiz.agent.harness.tool.contract.LogEvent;
import com.superbiz.agent.harness.tool.contract.LogPattern;
import com.superbiz.agent.harness.tool.contract.LogQueryScope;
import com.superbiz.agent.harness.tool.contract.LogSourceKind;
import com.superbiz.agent.harness.tool.contract.QueryLogsRequest;
import com.superbiz.agent.harness.tool.contract.QueryLogsToolResult;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/** Projects legacy Mock log JSON into the frozen query_logs contract. */
public final class QueryLogsResultProjector {
private static final Pattern SECRET = Pattern.compile(
"(?i)(password|token|secret|api[_-]?key)\\s*[:=]\\s*[^\\s,;]+" );
private static final Pattern POD = Pattern.compile("\\bpod-[A-Za-z0-9-]+\\b");
private static final Pattern HOST = Pattern.compile(
"\\b(?:redis|mysql|kafka|rabbitmq|node|host)-[A-Za-z0-9._-]+(?::\\d+)?\\b",
Pattern.CASE_INSENSITIVE);
private static final Pattern PID = Pattern.compile("(?i)\\bPID\\s*:\\s*\\d+");
private static final Pattern IP = Pattern.compile("\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d+)?\\b");
private static final Pattern SQL_LITERAL = Pattern.compile("'[^']*'");
private static final Pattern STACK_SUFFIX = Pattern.compile("\\s+at\\s+[\\w.$]+\\([^)]*\\).*$", Pattern.CASE_INSENSITIVE);
private final ObjectMapper objectMapper;
private final ToolProjectionLimits limits;
public QueryLogsResultProjector(ObjectMapper objectMapper, ToolProjectionLimits limits) {
this.objectMapper = objectMapper;
this.limits = limits;
}
public ProjectedToolResult project(QueryLogsRequest request, String toolCallId,
LogQueryScope scope, String rawResponse) throws Exception {
if (request == null || toolCallId == null || toolCallId.isBlank() || scope == null) {
throw new IllegalArgumentException("request, scope and tool call ID are required");
}
JsonNode root = objectMapper.readTree(rawResponse);
if (root == null || !root.isObject() || !root.path("success").isBoolean()
|| !root.path("success").asBoolean()) {
throw new IllegalArgumentException("log response indicates failure");
}
JsonNode logs = root.get("logs");
if (logs == null || !logs.isArray()) {
throw new IllegalArgumentException("log response has no logs array");
}
boolean truncated = logs.size() > limits.maxEvents();
List<LogEvent> allEvents = new ArrayList<>();
Map<String, PatternAccumulator> aggregates = new LinkedHashMap<>();
for (JsonNode log : logs) {
String timestamp = bounded(text(log, "timestamp"), limits.maxMessageChars());
String level = bounded(text(log, "level"), 32);
String service = bounded(text(log, "service"), 128);
String message = sanitize(text(log, "message"));
message = bounded(message, limits.maxMessageChars());
if (message.isBlank()) {
continue;
}
String example = message;
allEvents.add(new LogEvent(nullable(timestamp), nullable(level), nullable(service), message));
String patternKey = level + "\u0000" + service + "\u0000" + normalizePattern(message);
aggregates.computeIfAbsent(patternKey, ignored -> new PatternAccumulator(level, service, example))
.add(timestamp);
}
List<LogEvent> events = sample(allEvents, limits.maxEvents());
truncated |= events.size() < allEvents.size();
List<LogPattern> patterns = aggregates.values().stream()
.sorted(Comparator.comparingLong(PatternAccumulator::count).reversed()
.thenComparing(PatternAccumulator::example))
.limit(limits.maxPatterns())
.map(PatternAccumulator::toPattern)
.toList();
truncated |= aggregates.size() > patterns.size();
long matchCount = root.path("total").canConvertToLong() ? root.path("total").asLong() : allEvents.size();
QueryLogsToolResult result = new QueryLogsToolResult(
allEvents.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
toolCallId,
LogSourceKind.MOCK,
scope,
Math.max(0, matchCount),
events.size(),
patterns,
events,
truncated);
result = fitBudget(result);
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
}
private QueryLogsToolResult fitBudget(QueryLogsToolResult result) throws Exception {
QueryLogsToolResult current = result;
while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()
&& (!current.events().isEmpty() || !current.patterns().isEmpty())) {
List<LogEvent> events = new ArrayList<>(current.events());
List<LogPattern> patterns = new ArrayList<>(current.patterns());
if (!events.isEmpty()) {
events.remove(events.size() - 1);
} else {
patterns.remove(patterns.size() - 1);
}
current = new QueryLogsToolResult(
current.evidenceStatus(), current.toolCallId(), current.sourceKind(), current.scope(),
current.matchCount(), events.size(), patterns, events, true);
}
if (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()) {
throw new IllegalArgumentException("log projection exceeds total budget");
}
return current;
}
private static List<LogEvent> sample(List<LogEvent> events, int max) {
if (events.size() <= max) {
return List.copyOf(events);
}
List<LogEvent> sampled = new ArrayList<>(max);
for (int i = 0; i < max; i++) {
int index = max == 1 ? 0 : Math.round((float) i * (events.size() - 1) / (max - 1));
sampled.add(events.get(index));
}
return sampled;
}
private static String sanitize(String message) {
String value = SECRET.matcher(message).replaceAll("$1=[REDACTED]");
value = POD.matcher(value).replaceAll("[REDACTED_POD]");
value = HOST.matcher(value).replaceAll("[REDACTED_HOST]");
value = PID.matcher(value).replaceAll("PID:[REDACTED]");
value = IP.matcher(value).replaceAll("[REDACTED_IP]");
value = SQL_LITERAL.matcher(value).replaceAll("'[REDACTED_LITERAL]'");
return STACK_SUFFIX.matcher(value).replaceAll("").trim();
}
private static String normalizePattern(String message) {
return message.replaceAll("\\b\\d+(?:\\.\\d+)?\\b", "<n>")
.replaceAll("\\s+", " ").trim();
}
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? "" : value.asText("");
}
private static String bounded(String value, int max) {
if (value == null) {
return "";
}
return value.length() <= max ? value : value.substring(0, max);
}
private static String nullable(String value) {
return value == null || value.isBlank() ? null : value;
}
private static int bytes(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
private static final class PatternAccumulator {
private final String level;
private final String service;
private final String example;
private long count;
private String firstSeen;
private String lastSeen;
private PatternAccumulator(String level, String service, String example) {
this.level = level;
this.service = service;
this.example = example;
}
private PatternAccumulator add(String timestamp) {
count++;
if (firstSeen == null || timestamp.compareTo(firstSeen) < 0) {
firstSeen = timestamp;
}
if (lastSeen == null || timestamp.compareTo(lastSeen) > 0) {
lastSeen = timestamp;
}
return this;
}
private long count() {
return count;
}
private String example() {
return example;
}
private LogPattern toPattern() {
return new LogPattern(count, firstSeen, lastSeen, nullable(level), nullable(service), example);
}
}
}
@@ -0,0 +1,133 @@
package com.superbiz.agent.harness.tool.projection;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
import com.superbiz.agent.harness.tool.contract.RagEvidence;
import com.superbiz.agent.harness.tool.contract.RagToolRequest;
import com.superbiz.agent.harness.tool.contract.RagToolResult;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Projects legacy knowledge retrieval JSON into the frozen RAG contract. */
public final class RagResultProjector {
private final ObjectMapper objectMapper;
private final ToolProjectionLimits limits;
public RagResultProjector(ObjectMapper objectMapper, ToolProjectionLimits limits) {
this.objectMapper = objectMapper;
this.limits = limits;
}
public ProjectedToolResult project(RagToolRequest request, String toolCallId,
String rawResponse) throws Exception {
if (request == null || toolCallId == null || toolCallId.isBlank()) {
throw new IllegalArgumentException("request and tool call ID are required");
}
JsonNode root = objectMapper.readTree(rawResponse);
if (root == null || !root.isObject()) {
throw new IllegalArgumentException("RAG response must be a JSON object");
}
boolean truncated = false;
String query = bounded(request.query(), limits.maxQueryChars());
truncated = !query.equals(request.query());
List<RagEvidence> evidence = new ArrayList<>();
Set<String> documentIds = new HashSet<>();
JsonNode blocks = root.has("evidenceBlocks") ? root.get("evidenceBlocks") : root.get("evidence_blocks");
if (blocks != null && blocks.isArray()) {
int ordinal = 0;
for (JsonNode block : blocks) {
ordinal++;
if (evidence.size() >= limits.maxEvidence()) {
truncated = true;
break;
}
String excerpt = text(block, "content");
if (excerpt.isBlank()) {
excerpt = text(block, "excerpt");
}
if (excerpt.isBlank()) {
continue;
}
String source = text(block, "source");
String title = text(block, "title");
String documentId = firstNonBlank(text(block, "document_id"), source, title,
"legacy-document-" + ordinal);
if (!documentIds.add(documentId)) {
truncated = true;
continue;
}
String boundedExcerpt = bounded(excerpt, limits.maxExcerptChars());
truncated |= !boundedExcerpt.equals(excerpt);
evidence.add(new RagEvidence(
documentId,
nullable(source),
nullable(title),
nullable(text(block, "breadcrumb")),
boundedExcerpt));
}
if (blocks.size() > limits.maxEvidence()) {
truncated = true;
}
}
EvidenceStatus status = evidence.isEmpty()
? EvidenceStatus.NO_EVIDENCE
: EvidenceStatus.EVIDENCE_FOUND;
RagToolResult result = new RagToolResult(status, toolCallId, query, evidence, evidence.size(), truncated);
result = fitBudget(result, truncated);
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
}
private RagToolResult fitBudget(RagToolResult result, boolean truncated) throws Exception {
RagToolResult current = result;
while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()
&& !current.evidence().isEmpty()) {
List<RagEvidence> reduced = new ArrayList<>(current.evidence());
reduced.remove(reduced.size() - 1);
current = new RagToolResult(
reduced.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
current.toolCallId(), current.query(), reduced, reduced.size(), true);
}
if (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()) {
throw new IllegalArgumentException("RAG projection exceeds total budget");
}
return current;
}
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? "" : value.asText("");
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "unknown-document";
}
private static String nullable(String value) {
return value == null || value.isBlank() ? null : value;
}
private static String bounded(String value, int max) {
if (value == null) {
return "";
}
return value.length() <= max ? value : value.substring(0, max);
}
private static int bytes(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
}
@@ -0,0 +1,24 @@
package com.superbiz.agent.harness.tool.projection;
/** Bounds applied to Agent-facing projections. */
public record ToolProjectionLimits(
int maxEvidence,
int maxExcerptChars,
int maxPatterns,
int maxEvents,
int maxMessageChars,
int maxQueryChars,
int maxAgentUtf8Bytes) {
public ToolProjectionLimits {
if (maxEvidence <= 0 || maxExcerptChars <= 0 || maxPatterns <= 0
|| maxEvents <= 0 || maxMessageChars <= 0 || maxQueryChars <= 0
|| maxAgentUtf8Bytes <= 0) {
throw new IllegalArgumentException("projection limits must be positive");
}
}
public static ToolProjectionLimits defaults() {
return new ToolProjectionLimits(8, 1200, 12, 30, 1000, 500, 16_384);
}
}
@@ -0,0 +1,132 @@
package com.superbiz.agent.harness.tool.adapter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.HarnessCoreFixtures;
import com.superbiz.agent.harness.core.MutableClock;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
import com.superbiz.agent.harness.tool.boundary.ToolCallRequestEnvelope;
import com.superbiz.agent.harness.tool.contract.LogQueryScope;
import com.superbiz.agent.harness.tool.projection.QueryLogsResultProjector;
import com.superbiz.agent.harness.tool.projection.RagResultProjector;
import com.superbiz.agent.harness.tool.projection.ToolProjectionLimits;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationLimits;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationStore;
import com.superbiz.agent.harness.tool.store.CanonicalToolInvocation;
import com.superbiz.agent.harness.tool.store.DuplicateInvocationException;
import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ToolAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void ragAdapterUsesFrameworkIdAndCanonicalBoundary() throws Exception {
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
FakeStore store = new FakeStore();
ToolBoundary boundary = boundary(clock, store);
RagToolAdapter adapter = new RagToolAdapter(boundary, objectMapper,
new RagResultProjector(objectMapper, ToolProjectionLimits.defaults()),
query -> Map.of("found", true, "evidenceBlocks", java.util.List.of(
Map.of("source", "doc-1", "content", "exact excerpt"))));
RunContext context = HarnessCoreFixtures.core(clock).startRun("session", "run-rag");
ToolBoundaryResult result = adapter.execute(context, envelope("run-rag", "framework-rag-1",
"{\"query\":\"timeout\"}"));
assertEquals("framework-rag-1", result.toolCallId());
assertEquals(EvidenceStatus.EVIDENCE_FOUND, result.evidenceStatus());
assertTrue(result.agentResult().contains("exact excerpt"));
assertEquals("framework-rag-1", store.records.values().iterator().next().toolCallId());
}
@Test
void queryLogsAdapterInjectsLegacyControlsAndPreservesLogicalScope() throws Exception {
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
FakeStore store = new FakeStore();
AtomicReference<String> topic = new AtomicReference<>();
AtomicReference<String> region = new AtomicReference<>();
ToolBoundary boundary = boundary(clock, store);
QueryLogsToolAdapter adapter = new QueryLogsToolAdapter(boundary, objectMapper,
new QueryLogsResultProjector(objectMapper, ToolProjectionLimits.defaults()),
(actualRegion, actualTopic, query, limit) -> {
region.set(actualRegion);
topic.set(actualTopic);
return "{\"success\":true,\"total\":1,\"logs\":[{\"timestamp\":\"2026-07-21 09:59:00\",\"level\":\"WARN\",\"service\":\"order-service\",\"message\":\"timeout\"}]}";
}, clock);
RunContext context = HarnessCoreFixtures.core(clock).startRun("session", "run-log");
ToolBoundaryResult result = adapter.execute(context, envelope("run-log", "framework-log-1",
"{\"topic\":\"APPLICATION\",\"query\":\"timeout\",\"lookback_minutes\":30}"));
assertEquals("ap-guangzhou", region.get());
assertEquals("application-logs", topic.get());
assertEquals(EvidenceStatus.EVIDENCE_FOUND, result.evidenceStatus());
assertTrue(result.agentResult().contains("2026-07-21T09:30:00Z"));
assertTrue(result.agentResult().contains("2026-07-21T10:00:00Z"));
assertEquals("framework-log-1", result.toolCallId());
}
private ToolBoundary boundary(MutableClock clock, FakeStore store) {
DiagnosisHarnessCore core = HarnessCoreFixtures.core(clock);
return new ToolBoundary(core, new ToolCallKeyFactory("superbiz:harness:tool-call"),
store, objectMapper, clock);
}
private ToolCallRequestEnvelope envelope(String runId, String toolCallId, String request) {
return new ToolCallRequestEnvelope(runId, toolCallId, "tool", request, true, true);
}
private static final class FakeStore implements CanonicalInvocationStore {
private final CanonicalInvocationLimits limits = new CanonicalInvocationLimits(Duration.ofHours(1), 20_000, 10_000);
private final Map<String, CanonicalToolInvocation> records = new HashMap<>();
@Override
public CanonicalInvocationLimits limits() {
return limits;
}
@Override
public void begin(String key, CanonicalToolInvocation invocation) {
if (records.putIfAbsent(key, invocation) != null) {
throw new DuplicateInvocationException();
}
}
@Override
public Optional<CanonicalToolInvocation> find(String key) {
return Optional.ofNullable(records.get(key));
}
@Override
public CanonicalToolInvocation markReady(String key, String rawResponse, String agentResult,
EvidenceStatus evidenceStatus, Instant completedAt) {
CanonicalToolInvocation updated = records.get(key).markReady(rawResponse, agentResult, evidenceStatus, completedAt);
records.put(key, updated);
return updated;
}
@Override
public CanonicalToolInvocation markError(String key, String rawResponse, String errorCode, Instant completedAt) {
CanonicalToolInvocation updated = records.get(key).markError(rawResponse, errorCode, completedAt);
records.put(key, updated);
return updated;
}
}
}
@@ -0,0 +1,60 @@
package com.superbiz.agent.harness.tool.projection;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
import com.superbiz.agent.harness.tool.contract.LogQueryScope;
import com.superbiz.agent.harness.tool.contract.LogTopic;
import com.superbiz.agent.harness.tool.contract.QueryLogsRequest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class QueryLogsResultProjectorTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final LogQueryScope scope = new LogQueryScope(LogTopic.APPLICATION, "timeout",
"2026-07-21T10:00:00Z", "2026-07-21T10:30:00Z");
@Test
void projectsMockScopePatternsAndRedactsSensitiveFields() throws Exception {
QueryLogsResultProjector projector = new QueryLogsResultProjector(objectMapper,
new ToolProjectionLimits(2, 1200, 2, 2, 200, 100, 4096));
String raw = """
{"success":true,"total":3,"logs":[
{"timestamp":"2026-07-21 10:29:00","level":"ERROR","service":"order-service","instance":"pod-secret-1","message":"password=abc token:xyz pod-order-1 10.0.0.1 SELECT * FROM x WHERE name='alice' at a.B.c(A.java:1)","metrics":{"secret":"x"}},
{"timestamp":"2026-07-21 10:28:00","level":"ERROR","service":"order-service","instance":"pod-secret-2","message":"password=def token:qwe pod-order-2 10.0.0.2 SELECT * FROM x WHERE name='bob'"},
{"timestamp":"2026-07-21 10:27:00","level":"WARN","service":"order-service","message":"other"}]}
""";
ProjectedToolResult projected = projector.project(new QueryLogsRequest(LogTopic.APPLICATION, "timeout", 30),
"framework-log-1", scope, raw);
JsonNode json = objectMapper.readTree(projected.agentResult());
assertEquals(EvidenceStatus.EVIDENCE_FOUND, projected.evidenceStatus());
assertEquals("MOCK", json.path("source_kind").asText());
assertEquals("APPLICATION", json.path("scope").path("topic").asText());
assertEquals(3, json.path("match_count").asInt());
assertTrue(json.path("truncated").asBoolean());
assertFalse(projected.agentResult().contains("abc"));
assertFalse(projected.agentResult().contains("xyz"));
assertFalse(projected.agentResult().contains("pod-order"));
assertFalse(projected.agentResult().contains("10.0.0.1"));
assertFalse(projected.agentResult().contains("metrics"));
assertFalse(projected.agentResult().contains("instance"));
}
@Test
void returnsNoEvidenceForSuccessfulEmptyLogs() throws Exception {
QueryLogsResultProjector projector = new QueryLogsResultProjector(objectMapper, ToolProjectionLimits.defaults());
ProjectedToolResult projected = projector.project(new QueryLogsRequest(LogTopic.SYSTEM_EVENTS, "missing", 5),
"framework-log-2", scope, "{\"success\":true,\"total\":0,\"logs\":[]}");
assertEquals(EvidenceStatus.NO_EVIDENCE, projected.evidenceStatus());
assertEquals(0, objectMapper.readTree(projected.agentResult()).path("events").size());
}
}
@@ -0,0 +1,53 @@
package com.superbiz.agent.harness.tool.projection;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
import com.superbiz.agent.harness.tool.contract.RagToolRequest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RagResultProjectorTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void projectsOnlyDeduplicatedBoundedEvidence() throws Exception {
RagResultProjector projector = new RagResultProjector(objectMapper,
new ToolProjectionLimits(2, 8, 2, 2, 20, 30, 4096));
String raw = """
{"found":true,"evidenceBlocks":[
{"source":"doc-1","title":"One","breadcrumb":"a","content":"123456789","score":0.99},
{"source":"doc-1","title":"Duplicate","content":"ignored"},
{"source":"doc-2","title":"Two","content":"second"}],
"contextPack":{"packedText":"secret internal context"},"retrievalTrace":{"attempts":[]},"rerankTrace":{}}
""";
ProjectedToolResult projected = projector.project(new RagToolRequest("timeout"), "framework-1", raw);
JsonNode json = objectMapper.readTree(projected.agentResult());
assertEquals(EvidenceStatus.EVIDENCE_FOUND, projected.evidenceStatus());
assertEquals("framework-1", json.path("tool_call_id").asText());
assertEquals(2, json.path("returned_count").asInt());
assertEquals("12345678", json.path("evidence").get(0).path("excerpt").asText());
assertTrue(json.path("truncated").asBoolean());
assertFalse(projected.agentResult().contains("contextPack"));
assertFalse(projected.agentResult().contains("retrievalTrace"));
assertFalse(projected.agentResult().contains("score"));
}
@Test
void returnsNoEvidenceForEmptyUsableBlocks() throws Exception {
RagResultProjector projector = new RagResultProjector(objectMapper, ToolProjectionLimits.defaults());
ProjectedToolResult projected = projector.project(new RagToolRequest("unknown"), "framework-2",
"{\"found\":false,\"evidenceBlocks\":[{\"source\":\"doc\",\"content\":\"\"}]}");
assertEquals(EvidenceStatus.NO_EVIDENCE, projected.evidenceStatus());
assertEquals(0, objectMapper.readTree(projected.agentResult()).path("evidence").size());
}
}