@payconductor/houndsman 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -4
- package/assets/grammars/tree-sitter-javascript.wasm +0 -0
- package/assets/grammars/tree-sitter-tsx.wasm +0 -0
- package/assets/grammars/tree-sitter-typescript.wasm +0 -0
- package/assets/grammars/web-tree-sitter.wasm +0 -0
- package/package.json +11 -3
- package/src/cli/commands/command.ts +5 -0
- package/src/cli/commands/doctor-command.ts +116 -24
- package/src/cli/commands/hunt-command.ts +25 -0
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +10 -6
- package/src/core/ai/ai-client.ts +221 -0
- package/src/core/ai/ai-sdk-model-provider.ts +101 -0
- package/src/core/ai/errors.ts +36 -0
- package/src/core/ai/features/brief-enricher.ts +62 -0
- package/src/core/ai/features/classifier.ts +50 -0
- package/src/core/ai/features/context-summarizer.ts +31 -0
- package/src/core/ai/features/pre-analyzer.ts +51 -0
- package/src/core/ai/features/verifier.ts +45 -0
- package/src/core/ai/index.ts +56 -0
- package/src/core/ai/model-factory.ts +137 -0
- package/src/core/ai/prompts/base.ts +36 -0
- package/src/core/ai/prompts/brief.ts +87 -0
- package/src/core/ai/prompts/classification.ts +36 -0
- package/src/core/ai/prompts/context.ts +29 -0
- package/src/core/ai/prompts/pre-analysis.ts +49 -0
- package/src/core/ai/prompts/verify.ts +40 -0
- package/src/core/ai/schemas.ts +110 -0
- package/src/core/ai/types.ts +76 -0
- package/src/core/ai/usage-budget.ts +75 -0
- package/src/core/ai/usage-recorder.ts +79 -0
- package/src/core/assets.ts +16 -0
- package/src/core/ast/diff.ts +220 -0
- package/src/core/ast/impact.ts +127 -0
- package/src/core/ast/parser.ts +167 -0
- package/src/core/ast/service.ts +233 -0
- package/src/core/ast/symbols.ts +294 -0
- package/src/core/ast/types.ts +125 -0
- package/src/core/brief.ts +138 -0
- package/src/core/commands/findings.ts +65 -2
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +159 -10
- package/src/core/commands/update.ts +5 -3
- package/src/core/config.ts +85 -0
- package/src/core/defaults.ts +27 -0
- package/src/core/domain/watchdog.ts +43 -5
- package/src/core/engine/houndsman-engine.ts +16 -1
- package/src/core/engine/review-scope.ts +52 -6
- package/src/core/engine/review-session.ts +27 -2
- package/src/core/env.ts +101 -0
- package/src/core/file-classifier.ts +25 -4
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/jev/jev-client.ts +8 -7
- package/src/core/jev/jev-verify.ts +2 -1
- package/src/core/logging/index.ts +17 -0
- package/src/core/logging/logger.ts +187 -0
- package/src/core/orchestrator.ts +357 -9
- package/src/core/project-config.ts +123 -1
- package/src/core/registry-check.ts +148 -6
- package/src/core/renderers/manifest-writer.ts +33 -0
- package/src/core/rule-text.ts +97 -0
- package/src/core/services/file-classifier.ts +10 -3
- package/src/core/services/gh-client.ts +38 -22
- package/src/core/services/verifier.ts +14 -3
- package/src/core/storage/paths.ts +6 -5
- package/src/core/storage/types.ts +6 -0
- package/src/core/storage/work-area.ts +12 -0
- package/src/core/types.ts +37 -1
- package/src/core/verify.ts +17 -5
- package/src/harness/README.md +15 -0
- package/src/harness/agent/agent-context.ts +33 -0
- package/src/harness/agent/agent-errors.ts +30 -0
- package/src/harness/agent/agent-factory.ts +45 -0
- package/src/harness/agent/agent-reporter.ts +34 -0
- package/src/harness/agent/agent-result.ts +16 -0
- package/src/harness/agent/agent-runner.ts +32 -0
- package/src/harness/agent/agent-scheduler.ts +37 -0
- package/src/harness/agent/review-agent.ts +391 -0
- package/src/harness/config/harness-config.ts +84 -0
- package/src/harness/hunt/hunt-harness.ts +83 -0
- package/src/harness/hunt/hunt-options.ts +75 -0
- package/src/harness/hunt/hunt-result.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +372 -0
- package/src/harness/hunt/review-planner.ts +98 -0
- package/src/harness/index.ts +36 -0
- package/src/harness/investigation/agent-state.ts +134 -0
- package/src/harness/investigation/evidence-verifier.ts +129 -0
- package/src/harness/investigation/evidence.ts +110 -0
- package/src/harness/investigation/finding-critic.ts +96 -0
- package/src/harness/investigation/investigation-planner.ts +118 -0
- package/src/harness/investigation/investigation-task.ts +10 -0
- package/src/harness/investigation/review-journal.ts +87 -0
- package/src/harness/investigation/rule-analyzer.ts +178 -0
- package/src/harness/investigation/rule-signals.ts +110 -0
- package/src/harness/prompts/agent-schemas.ts +57 -0
- package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
- package/src/harness/prompts/states/conclude-prompt.ts +16 -0
- package/src/harness/prompts/states/investigate-prompt.ts +37 -0
- package/src/harness/prompts/states/orient-prompt.ts +15 -0
- package/src/harness/prompts/states/verify-prompt.ts +22 -0
- package/src/harness/tools/agent-tool.ts +102 -0
- package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
- package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
- package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
- package/src/harness/tools/builtin/find-references-tool.ts +66 -0
- package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
- package/src/harness/tools/builtin/helpers.ts +115 -0
- package/src/harness/tools/builtin/impact-tool.ts +48 -0
- package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
- package/src/harness/tools/builtin/index.ts +59 -0
- package/src/harness/tools/builtin/list-files-tool.ts +85 -0
- package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
- package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
- package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
- package/src/harness/tools/builtin/search-code-tool.ts +65 -0
- package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
- package/src/harness/tools/path-guard.ts +90 -0
- package/src/harness/tools/result-limiter.ts +49 -0
- package/src/harness/tools/secret-redactor.ts +54 -0
- package/src/harness/tools/tool-cache.ts +50 -0
- package/src/harness/tools/tool-call-budget.ts +60 -0
- package/src/harness/tools/tool-context.ts +5 -0
- package/src/harness/tools/tool-middleware.ts +105 -0
- package/src/harness/tools/tool-policy.ts +51 -0
- package/src/harness/tools/tool-registry.ts +112 -0
- package/src/harness/tools/tool-result.ts +15 -0
- package/src/harness/tools/toolbox.ts +36 -0
- package/src/harness/validation/finding-validator.ts +119 -0
- package/src/index.ts +46 -0
- package/src/server/client/App.tsx +38 -12
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/Sidebar.tsx +397 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +19 -0
- package/src/server/client/index.tsx +1 -1
- package/src/server/client/utils/constants.ts +29 -5
- package/src/server/client/utils/format.ts +47 -2
- package/src/server/client/utils/types.ts +88 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +115 -5
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +202 -0
- package/src/server/run-repository.ts +33 -1
- package/src/skill/OUTPUT-FORMAT.md +12 -0
- package/src/skill/SETUP.md +6 -2
- package/src/skill/hunt.md +14 -2
- package/src/skill/verify.md +1 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
- package/src/tests/ai-core.test.ts +120 -0
- package/src/tests/ai-features.test.ts +183 -0
- package/src/tests/ast.test.ts +335 -0
- package/src/tests/cli.test.ts +33 -0
- package/src/tests/env.test.ts +69 -0
- package/src/tests/fallback.test.ts +172 -0
- package/src/tests/fixtures/fake-agent-tools.ts +69 -0
- package/src/tests/fixtures/fake-investigation.ts +64 -0
- package/src/tests/fixtures/fake-model-provider.ts +48 -0
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/harness-agent.test.ts +122 -0
- package/src/tests/harness-investigation.test.ts +206 -0
- package/src/tests/harness-tools.test.ts +148 -0
- package/src/tests/hunt-command.test.ts +212 -0
- package/src/tests/logger.test.ts +83 -0
- package/src/tests/review-scope.test.ts +11 -11
- package/src/tests/rule-analyzer.test.ts +61 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +295 -2
- package/src/tests/tool-contracts.test.ts +82 -0
- package/src/tests/verify.test.ts +1 -1
- package/src/server/client/RunList.tsx +0 -75
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limites de resultado das tools (linhas, bytes, resultados).
|
|
3
|
+
*
|
|
4
|
+
* Nenhuma tool devolve arquivo inteiro por default. Os limites são parte do
|
|
5
|
+
* contrato: o agente recebe `truncated: true` e um `hint` para refinar.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Limites canônicos das tools. */
|
|
9
|
+
export const TOOL_LIMITS = {
|
|
10
|
+
maxLines: 200,
|
|
11
|
+
maxBytes: 32 * 1024,
|
|
12
|
+
maxResults: 50,
|
|
13
|
+
maxFiles: 200,
|
|
14
|
+
maxSnippetLines: 60,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** Resultado de um corte de linhas. */
|
|
18
|
+
export type LimitedLines = { lines: string[]; truncated: boolean };
|
|
19
|
+
|
|
20
|
+
/** Mantém no máximo `max` linhas. */
|
|
21
|
+
export function limitLines(
|
|
22
|
+
lines: readonly string[],
|
|
23
|
+
max: number = TOOL_LIMITS.maxLines,
|
|
24
|
+
): LimitedLines {
|
|
25
|
+
if (lines.length <= max) return { lines: [...lines], truncated: false };
|
|
26
|
+
return { lines: lines.slice(0, max), truncated: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Corta um texto em bytes (UTF-8) sem estourar o orçamento. */
|
|
30
|
+
export function limitText(
|
|
31
|
+
text: string,
|
|
32
|
+
maxBytes: number = TOOL_LIMITS.maxBytes,
|
|
33
|
+
): {
|
|
34
|
+
text: string;
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
} {
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
if (encoder.encode(text).length <= maxBytes) return { text, truncated: false };
|
|
39
|
+
let end = Math.min(text.length, maxBytes);
|
|
40
|
+
while (end > 0 && encoder.encode(text.slice(0, end)).length > maxBytes) end--;
|
|
41
|
+
return { text: text.slice(0, Math.max(0, end)), truncated: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Numera as linhas a partir de `startLine` (1-based), como `cat -n`. */
|
|
45
|
+
export function numberLines(startLine: number, lines: readonly string[]): string {
|
|
46
|
+
return lines
|
|
47
|
+
.map((line, index) => `${String(startLine + index).padStart(4, " ")}\t${line}`)
|
|
48
|
+
.join("\n");
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redação de segredos em resultados de tools/logs.
|
|
3
|
+
*
|
|
4
|
+
* Cobre chaves do ambiente do houndsman, tokens comuns (`sk-...`, `Bearer`) e
|
|
5
|
+
* pares `chave=valor` sensíveis. Nunca loga prompt nem conteúdo de `.env`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { env } from "../../core/env";
|
|
9
|
+
|
|
10
|
+
/** Padrões genéricos de segredo. */
|
|
11
|
+
const PATTERNS: readonly [RegExp, string][] = [
|
|
12
|
+
[/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]"],
|
|
13
|
+
[/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, "Bearer [REDACTED]"],
|
|
14
|
+
[
|
|
15
|
+
/\b(api[_-]?key|token|secret|password|passwd|authorization)\b\s*[:=]\s*["']?[^\s"',}]+/gi,
|
|
16
|
+
"$1: [REDACTED]",
|
|
17
|
+
],
|
|
18
|
+
[/\b[A-Fa-f0-9]{32,}\b/g, "[REDACTED]"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Substitui os valores reais do ambiente (sem revelá-los). */
|
|
22
|
+
function redactEnvValues(text: string): string {
|
|
23
|
+
let result = text;
|
|
24
|
+
for (const value of [env.deepSeekApiKey, env.typeSafeApiKey]) {
|
|
25
|
+
if (!value || value.length < 6) continue;
|
|
26
|
+
result = result.split(value).join("[REDACTED]");
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Redige segredos de um texto. */
|
|
32
|
+
export function redactSecrets(text: string): string {
|
|
33
|
+
let result = redactEnvValues(text);
|
|
34
|
+
for (const [pattern, replacement] of PATTERNS) {
|
|
35
|
+
result = result.replace(pattern, replacement);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Redige recursivamente strings de um valor (preserva a forma). */
|
|
41
|
+
export function redactValue<T>(value: T): T {
|
|
42
|
+
if (typeof value === "string") return redactSecrets(value) as unknown as T;
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.map((item) => redactValue(item)) as unknown as T;
|
|
45
|
+
}
|
|
46
|
+
if (value && typeof value === "object") {
|
|
47
|
+
const output: Record<string, unknown> = {};
|
|
48
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
49
|
+
output[key] = redactValue(item);
|
|
50
|
+
}
|
|
51
|
+
return output as unknown as T;
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache de resultados de tools por execução de agente.
|
|
3
|
+
*
|
|
4
|
+
* Chave = tool + input serializado. Respeita TTL declarado no metadata. Evita
|
|
5
|
+
* reler o mesmo trecho em estados diferentes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Entrada do cache. */
|
|
9
|
+
type CacheEntry = { value: unknown; expiresAt: number };
|
|
10
|
+
|
|
11
|
+
/** Cache em memória (escopo de uma review/agente). */
|
|
12
|
+
export class ToolCache {
|
|
13
|
+
private readonly entries = new Map<string, CacheEntry>();
|
|
14
|
+
|
|
15
|
+
/** Chave estável (JSON com chaves ordenadas). */
|
|
16
|
+
static key(tool: string, input: unknown): string {
|
|
17
|
+
return `${tool}:${stableStringify(input)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get<T>(key: string): T | undefined {
|
|
21
|
+
const entry = this.entries.get(key);
|
|
22
|
+
if (!entry) return undefined;
|
|
23
|
+
if (entry.expiresAt <= Date.now()) {
|
|
24
|
+
this.entries.delete(key);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return entry.value as T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
set(key: string, value: unknown, ttlMs = 60_000): void {
|
|
31
|
+
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clear(): void {
|
|
35
|
+
this.entries.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Serializa um valor com chaves ordenadas. */
|
|
40
|
+
function stableStringify(value: unknown): string {
|
|
41
|
+
if (value === null || typeof value !== "object")
|
|
42
|
+
return JSON.stringify(value) ?? "null";
|
|
43
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
44
|
+
const obj = value as Record<string, unknown>;
|
|
45
|
+
const body = Object.keys(obj)
|
|
46
|
+
.sort()
|
|
47
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
|
|
48
|
+
.join(",");
|
|
49
|
+
return `{${body}}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orçamento de chamadas de tool por agente.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Erro de orçamento de tools. */
|
|
6
|
+
export class ToolBudgetError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ToolBudgetError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Orçamento de chamadas (total e por tool). */
|
|
14
|
+
export class ToolCallBudget {
|
|
15
|
+
private readonly maxCalls: number;
|
|
16
|
+
private readonly maxPerTool: number;
|
|
17
|
+
private used = 0;
|
|
18
|
+
private readonly perTool = new Map<string, number>();
|
|
19
|
+
|
|
20
|
+
constructor(maxCalls = 30, maxPerTool = 12) {
|
|
21
|
+
this.maxCalls = maxCalls;
|
|
22
|
+
this.maxPerTool = maxPerTool;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get total(): number {
|
|
26
|
+
return this.used;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get remaining(): number {
|
|
30
|
+
return Math.max(0, this.maxCalls - this.used);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `true` quando ainda cabe a chamada. */
|
|
34
|
+
canCall(name: string): boolean {
|
|
35
|
+
if (this.used >= this.maxCalls) return false;
|
|
36
|
+
return (this.perTool.get(name) ?? 0) < this.maxPerTool;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Consome uma chamada; lança quando o orçamento acabou. */
|
|
40
|
+
consume(name: string): void {
|
|
41
|
+
if (this.used >= this.maxCalls) {
|
|
42
|
+
throw new ToolBudgetError(
|
|
43
|
+
`Orçamento de ${this.maxCalls} chamada(s) de tool por agente esgotado.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const calls = this.perTool.get(name) ?? 0;
|
|
47
|
+
if (calls >= this.maxPerTool) {
|
|
48
|
+
throw new ToolBudgetError(
|
|
49
|
+
`Limite de ${this.maxPerTool} chamada(s) da tool '${name}' atingido.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
this.used++;
|
|
53
|
+
this.perTool.set(name, calls + 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Contagem por tool (transcript). */
|
|
57
|
+
snapshot(): Record<string, number> {
|
|
58
|
+
return Object.fromEntries(this.perTool);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware de execução das tools.
|
|
3
|
+
*
|
|
4
|
+
* Cadeia: validação Zod → cache → budget → execução → limites → redaction →
|
|
5
|
+
* auditoria. Toda tool passa por aqui; nenhuma executa direto.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolResult } from "./agent-tool";
|
|
9
|
+
import { limitText, TOOL_LIMITS } from "./result-limiter";
|
|
10
|
+
import { redactSecrets, redactValue } from "./secret-redactor";
|
|
11
|
+
import { ToolCache } from "./tool-cache";
|
|
12
|
+
import { ToolBudgetError } from "./tool-call-budget";
|
|
13
|
+
import type { ToolContext } from "./tool-context";
|
|
14
|
+
import type { AgentStateName } from "./tool-policy";
|
|
15
|
+
import type { RegisteredTool } from "./tool-registry";
|
|
16
|
+
|
|
17
|
+
/** Executor com validação, cache, budget, limite e auditoria. */
|
|
18
|
+
export class ToolMiddleware {
|
|
19
|
+
async run(
|
|
20
|
+
tool: RegisteredTool,
|
|
21
|
+
input: unknown,
|
|
22
|
+
context: ToolContext,
|
|
23
|
+
state: AgentStateName,
|
|
24
|
+
): Promise<ToolResult<unknown>> {
|
|
25
|
+
const parsed = tool.inputSchema.safeParse(input);
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `input inválido para '${tool.metadata.name}': ${parsed.error.message}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const maxPerAgent = tool.metadata.maxCallsPerAgent;
|
|
34
|
+
if (
|
|
35
|
+
maxPerAgent !== undefined &&
|
|
36
|
+
context.state.callsOf(tool.metadata.name) >= maxPerAgent
|
|
37
|
+
) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: `Limite de ${maxPerAgent} chamada(s) da tool '${tool.metadata.name}'.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const key = ToolCache.key(tool.metadata.name, parsed.data);
|
|
45
|
+
const cached = context.cache.get<ToolResult<unknown>>(key);
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
context.budget.consume(tool.metadata.name);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error: error instanceof ToolBudgetError ? error.message : String(error),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const started = performance.now();
|
|
58
|
+
try {
|
|
59
|
+
// Contravariância: a tool concreta aceita seu próprio input.
|
|
60
|
+
const result = (await tool.execute(
|
|
61
|
+
parsed.data as never,
|
|
62
|
+
context,
|
|
63
|
+
)) as ToolResult<unknown>;
|
|
64
|
+
const limited = limitResult(result);
|
|
65
|
+
const redacted = redactValue(limited);
|
|
66
|
+
context.cache.set(key, redacted, tool.metadata.cacheTtlMs ?? 60_000);
|
|
67
|
+
context.state.recordToolCall(tool.metadata.name);
|
|
68
|
+
const stored = (redacted.evidence ?? []).map(
|
|
69
|
+
(evidence) =>
|
|
70
|
+
context.state.getEvidence(evidence.id) ??
|
|
71
|
+
context.state.addEvidence(evidence),
|
|
72
|
+
);
|
|
73
|
+
if (stored.length > 0) redacted.evidence = stored;
|
|
74
|
+
context.logger.journal.recordToolCall(
|
|
75
|
+
tool.metadata.name,
|
|
76
|
+
state,
|
|
77
|
+
performance.now() - started,
|
|
78
|
+
redacted,
|
|
79
|
+
);
|
|
80
|
+
return redacted;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const message = redactSecrets(
|
|
83
|
+
error instanceof Error ? error.message : String(error),
|
|
84
|
+
);
|
|
85
|
+
context.logger.journal.recordToolCall(
|
|
86
|
+
tool.metadata.name,
|
|
87
|
+
state,
|
|
88
|
+
performance.now() - started,
|
|
89
|
+
{ ok: false, error: message },
|
|
90
|
+
);
|
|
91
|
+
return { ok: false, error: message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Aplica tetos de texto ao resultado da tool. */
|
|
97
|
+
function limitResult(result: ToolResult<unknown>): ToolResult<unknown> {
|
|
98
|
+
const copy: ToolResult<unknown> = { ...result };
|
|
99
|
+
if (typeof copy.data === "string") {
|
|
100
|
+
const limited = limitText(copy.data, TOOL_LIMITS.maxBytes);
|
|
101
|
+
copy.data = limited.text;
|
|
102
|
+
if (limited.truncated) copy.truncated = true;
|
|
103
|
+
}
|
|
104
|
+
return copy;
|
|
105
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Política de tools por estado do agente.
|
|
3
|
+
*
|
|
4
|
+
* O modelo só vê as tools permitidas no estado atual. Adicionar uma tool não
|
|
5
|
+
* exige alterar o agente — basta declarar capabilities compatíveis.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolCapability, ToolMetadata } from "./agent-tool";
|
|
9
|
+
|
|
10
|
+
/** Estados da máquina de investigação. */
|
|
11
|
+
export type AgentStateName = "ORIENT" | "PLAN" | "INVESTIGATE" | "VERIFY" | "CONCLUDE";
|
|
12
|
+
|
|
13
|
+
/** Capabilities liberadas por estado. */
|
|
14
|
+
const DEFAULT_POLICY: Record<AgentStateName, readonly ToolCapability[]> = {
|
|
15
|
+
ORIENT: ["read", "outline", "ast", "impact", "rules", "symbol"],
|
|
16
|
+
PLAN: ["rules", "symbol", "impact", "ast"],
|
|
17
|
+
INVESTIGATE: [
|
|
18
|
+
"read",
|
|
19
|
+
"search",
|
|
20
|
+
"outline",
|
|
21
|
+
"symbol",
|
|
22
|
+
"references",
|
|
23
|
+
"ast",
|
|
24
|
+
"impact",
|
|
25
|
+
"rules",
|
|
26
|
+
"tests",
|
|
27
|
+
"docs",
|
|
28
|
+
],
|
|
29
|
+
VERIFY: ["symbol", "references", "search", "read"],
|
|
30
|
+
CONCLUDE: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Política substituível. */
|
|
34
|
+
export class ToolPolicy {
|
|
35
|
+
private readonly policy: Record<AgentStateName, readonly ToolCapability[]>;
|
|
36
|
+
|
|
37
|
+
constructor(policy: Partial<Record<AgentStateName, readonly ToolCapability[]>> = {}) {
|
|
38
|
+
this.policy = { ...DEFAULT_POLICY, ...policy };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Capabilities liberadas no estado. */
|
|
42
|
+
capabilities(state: AgentStateName): readonly ToolCapability[] {
|
|
43
|
+
return this.policy[state];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `true` se a tool pode ser usada no estado. */
|
|
47
|
+
allows(state: AgentStateName, metadata: ToolMetadata): boolean {
|
|
48
|
+
const allowed = this.policy[state];
|
|
49
|
+
return metadata.capabilities.some((capability) => allowed.includes(capability));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registro das tools do harness.
|
|
3
|
+
*
|
|
4
|
+
* Módulos registram tools; nomes duplicados falham no startup. O registry gera
|
|
5
|
+
* as descrições do prompt a partir do metadata (nunca hardcoded).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AiTool } from "../../core/ai";
|
|
9
|
+
import type {
|
|
10
|
+
RegisterableTool,
|
|
11
|
+
ToolContext,
|
|
12
|
+
ToolMetadata,
|
|
13
|
+
ToolModule,
|
|
14
|
+
ToolRegistryLike,
|
|
15
|
+
ToolResult,
|
|
16
|
+
} from "./agent-tool";
|
|
17
|
+
import type { ToolMiddleware } from "./tool-middleware";
|
|
18
|
+
import type { AgentStateName, ToolPolicy } from "./tool-policy";
|
|
19
|
+
|
|
20
|
+
/** Tool registrada, com a forma type-erased aceita pelo middleware. */
|
|
21
|
+
export type RegisteredTool = RegisterableTool;
|
|
22
|
+
|
|
23
|
+
/** Registry de tools. */
|
|
24
|
+
export class ToolRegistry implements ToolRegistryLike {
|
|
25
|
+
private readonly tools = new Map<string, RegisteredTool>();
|
|
26
|
+
|
|
27
|
+
/** Registra uma tool; nome duplicado é erro de configuração. */
|
|
28
|
+
registerTool(tool: RegisterableTool): void {
|
|
29
|
+
if (this.tools.has(tool.metadata.name)) {
|
|
30
|
+
throw new Error(`Tool duplicada: '${tool.metadata.name}'.`);
|
|
31
|
+
}
|
|
32
|
+
this.tools.set(tool.metadata.name, tool);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Registra um módulo (agrupa tools). */
|
|
36
|
+
registerModule(module: ToolModule): void {
|
|
37
|
+
module.register(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Tool por nome. */
|
|
41
|
+
get(name: string): RegisteredTool | undefined {
|
|
42
|
+
return this.tools.get(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Todas as tools (ordem de registro). */
|
|
46
|
+
list(): RegisteredTool[] {
|
|
47
|
+
return [...this.tools.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tools com uma capability. */
|
|
51
|
+
listByCapability(capability: string): RegisteredTool[] {
|
|
52
|
+
return this.list().filter((tool) =>
|
|
53
|
+
(tool.metadata.capabilities as readonly string[]).includes(capability),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Descrição compacta das tools permitidas (para o prompt). */
|
|
58
|
+
describeForPrompt(policy: ToolPolicy, state: AgentStateName): string {
|
|
59
|
+
return this.list()
|
|
60
|
+
.filter((tool) => policy.allows(state, tool.metadata))
|
|
61
|
+
.map(
|
|
62
|
+
(tool) =>
|
|
63
|
+
`- ${tool.metadata.name} (${tool.metadata.capabilities.join(", ")}): ${tool.metadata.description}`,
|
|
64
|
+
)
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Converte as tools permitidas no formato do modelo. */
|
|
69
|
+
toModelTools(
|
|
70
|
+
policy: ToolPolicy,
|
|
71
|
+
state: AgentStateName,
|
|
72
|
+
context: ToolContext,
|
|
73
|
+
middleware: ToolMiddleware,
|
|
74
|
+
): Record<string, AiTool> {
|
|
75
|
+
const entries = this.list()
|
|
76
|
+
.filter((tool) => policy.allows(state, tool.metadata))
|
|
77
|
+
.map((tool) => [
|
|
78
|
+
tool.metadata.name,
|
|
79
|
+
{
|
|
80
|
+
name: tool.metadata.name,
|
|
81
|
+
description: tool.metadata.description,
|
|
82
|
+
inputSchema: tool.inputSchema,
|
|
83
|
+
execute: async (input: unknown): Promise<string> => {
|
|
84
|
+
const result = await middleware.run(tool, input, context, state);
|
|
85
|
+
return JSON.stringify(result);
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
return Object.fromEntries(entries);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Metadados registrados. */
|
|
93
|
+
metadata(): ToolMetadata[] {
|
|
94
|
+
return this.list().map((tool) => tool.metadata);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Executa uma tool com middleware (usado nos testes). */
|
|
98
|
+
execute(
|
|
99
|
+
name: string,
|
|
100
|
+
input: unknown,
|
|
101
|
+
context: ToolContext,
|
|
102
|
+
middleware: ToolMiddleware,
|
|
103
|
+
state: AgentStateName = "INVESTIGATE",
|
|
104
|
+
): Promise<ToolResult<unknown>> {
|
|
105
|
+
const tool = this.tools.get(name);
|
|
106
|
+
if (!tool)
|
|
107
|
+
return Promise.resolve({ ok: false, error: `tool desconhecida: ${name}` });
|
|
108
|
+
return middleware.run(tool, input, context, state) as Promise<
|
|
109
|
+
ToolResult<unknown>
|
|
110
|
+
>;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resultado padrão de uma tool (reexport de contrato).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type { ToolResult } from "./agent-tool";
|
|
6
|
+
|
|
7
|
+
/** Resultado de sucesso. */
|
|
8
|
+
export function ok<T>(data: T, extra: Record<string, unknown> = {}) {
|
|
9
|
+
return { ok: true as const, data, ...extra };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Resultado de erro estruturado. */
|
|
13
|
+
export function fail(error: string) {
|
|
14
|
+
return { ok: false as const, error };
|
|
15
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visão de tools por agente (registry + policy + middleware).
|
|
3
|
+
*
|
|
4
|
+
* O agente pede `toModelTools(state)` e recebe apenas o que pode usar naquele
|
|
5
|
+
* estado, já com cache/budget/redaction aplicados.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AiTool } from "../../core/ai";
|
|
9
|
+
import type { ToolContext } from "./tool-context";
|
|
10
|
+
import type { ToolMiddleware } from "./tool-middleware";
|
|
11
|
+
import type { AgentStateName, ToolPolicy } from "./tool-policy";
|
|
12
|
+
import type { ToolRegistry } from "./tool-registry";
|
|
13
|
+
|
|
14
|
+
/** Fachada por agente. */
|
|
15
|
+
export class Toolbox {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly registry: ToolRegistry,
|
|
18
|
+
readonly policy: ToolPolicy,
|
|
19
|
+
readonly middleware: ToolMiddleware,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/** Tools permitidas no estado, no formato do modelo. */
|
|
23
|
+
toModelTools(state: AgentStateName, context: ToolContext): Record<string, AiTool> {
|
|
24
|
+
return this.registry.toModelTools(this.policy, state, context, this.middleware);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Descrição das tools permitidas (prompt). */
|
|
28
|
+
describe(state: AgentStateName): string {
|
|
29
|
+
return this.registry.describeForPrompt(this.policy, state);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Executa uma tool fora do modelo (testes/uso determinístico). */
|
|
33
|
+
execute(name: string, input: unknown, context: ToolContext, state: AgentStateName) {
|
|
34
|
+
return this.registry.execute(name, input, context, this.middleware, state);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validação final do relatório de um watchdog.
|
|
3
|
+
*
|
|
4
|
+
* Descarta findings que citam regra inexistente, arquivo fora do domínio/impacto,
|
|
5
|
+
* overflow (não lido) ou linha inválida. `reviewedFiles` só aceita arquivos
|
|
6
|
+
* existentes; overflow entra em `skipped`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import type { WatchdogReport } from "../../core/report-builder";
|
|
11
|
+
import { emergentText, isEmergentRule } from "../../core/rule-text";
|
|
12
|
+
import { SEVERITY_ORDER, type Severity } from "../../core/types";
|
|
13
|
+
import { capSeverity } from "../investigation/finding-critic";
|
|
14
|
+
|
|
15
|
+
/** Contexto de validação. */
|
|
16
|
+
export type ValidationContext = {
|
|
17
|
+
watchdogId: string;
|
|
18
|
+
knownRules: ReadonlySet<string>;
|
|
19
|
+
allowedFiles: ReadonlySet<string>;
|
|
20
|
+
overflow: ReadonlySet<string>;
|
|
21
|
+
readFile: (path: string) => string | null;
|
|
22
|
+
/** Aceita findings `Lógica:`/`Domínio:` fora das regras. */
|
|
23
|
+
allowEmergentFindings?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Resultado da validação. */
|
|
27
|
+
export type ValidationResult = {
|
|
28
|
+
report: WatchdogReport;
|
|
29
|
+
dropped: { finding: string; reason: string }[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const SEVERITIES = new Set<string>(SEVERITY_ORDER);
|
|
33
|
+
|
|
34
|
+
/** Validador de relatórios. */
|
|
35
|
+
export class FindingValidator {
|
|
36
|
+
private readonly context: ValidationContext;
|
|
37
|
+
|
|
38
|
+
constructor(context: ValidationContext) {
|
|
39
|
+
this.context = context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Valida/normaliza o relatório. */
|
|
43
|
+
validate(report: WatchdogReport): ValidationResult {
|
|
44
|
+
const dropped: { finding: string; reason: string }[] = [];
|
|
45
|
+
const findings = report.findings
|
|
46
|
+
.filter((finding) => {
|
|
47
|
+
const reason = this.reasonToDrop(finding);
|
|
48
|
+
if (!reason) return true;
|
|
49
|
+
dropped.push({
|
|
50
|
+
finding: `${finding.file}:${finding.line ?? "?"}`,
|
|
51
|
+
reason,
|
|
52
|
+
});
|
|
53
|
+
return false;
|
|
54
|
+
})
|
|
55
|
+
.map((finding) =>
|
|
56
|
+
isEmergentRule(finding.rule)
|
|
57
|
+
? {
|
|
58
|
+
...finding,
|
|
59
|
+
severity: capSeverity(finding.severity, "attention"),
|
|
60
|
+
}
|
|
61
|
+
: finding,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const reviewedFiles = report.reviewedFiles
|
|
65
|
+
.filter((file) => this.context.allowedFiles.has(file))
|
|
66
|
+
.filter((file) => this.context.readFile(file) !== null);
|
|
67
|
+
|
|
68
|
+
const skipped = new Set(report.skipped);
|
|
69
|
+
for (const file of this.context.overflow) skipped.add(`${file} (budget)`);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
report: {
|
|
73
|
+
watchdogId: this.context.watchdogId,
|
|
74
|
+
reviewedFiles: [...new Set(reviewedFiles)],
|
|
75
|
+
skipped: [...skipped],
|
|
76
|
+
findings,
|
|
77
|
+
},
|
|
78
|
+
dropped,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Motivo para descartar (ou `null` se válido). */
|
|
83
|
+
private reasonToDrop(finding: {
|
|
84
|
+
rule: string;
|
|
85
|
+
severity: Severity;
|
|
86
|
+
file: string;
|
|
87
|
+
line?: number;
|
|
88
|
+
}): string | null {
|
|
89
|
+
if (isEmergentRule(finding.rule)) {
|
|
90
|
+
if (!this.context.allowEmergentFindings)
|
|
91
|
+
return "finding emergente desabilitado";
|
|
92
|
+
if (!emergentText(finding.rule)) return "finding emergente sem descrição";
|
|
93
|
+
} else if (!this.context.knownRules.has(finding.rule.trim())) {
|
|
94
|
+
return "regra inexistente";
|
|
95
|
+
}
|
|
96
|
+
if (!SEVERITIES.has(finding.severity)) return "severidade inválida";
|
|
97
|
+
if (this.context.overflow.has(finding.file)) return "arquivo fora do orçamento";
|
|
98
|
+
if (!this.context.allowedFiles.has(finding.file))
|
|
99
|
+
return "arquivo fora do domínio";
|
|
100
|
+
const content = this.context.readFile(finding.file);
|
|
101
|
+
if (content === null) return "arquivo ausente";
|
|
102
|
+
if (finding.line !== undefined) {
|
|
103
|
+
const total = content.split("\n").length;
|
|
104
|
+
if (finding.line < 1 || finding.line > total) return "linha fora do arquivo";
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Leitor de arquivos padrão (UTF-8; `null` se ausente). */
|
|
111
|
+
export function defaultFileReader(root: string): (path: string) => string | null {
|
|
112
|
+
return (path: string) => {
|
|
113
|
+
try {
|
|
114
|
+
return readFileSync(`${root}/${path}`, "utf8");
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|