diff --git a/src/main/java/dev/talos/cli/prompt/PromptDebugInspector.java b/src/main/java/dev/talos/cli/prompt/PromptDebugInspector.java index bf4e3930..46eaa7fb 100644 --- a/src/main/java/dev/talos/cli/prompt/PromptDebugInspector.java +++ b/src/main/java/dev/talos/cli/prompt/PromptDebugInspector.java @@ -2,8 +2,7 @@ import dev.talos.core.context.ContextLedgerCapture; import dev.talos.core.context.ContextLedgerSnapshot; -import dev.talos.runtime.intent.TaskIntent; -import dev.talos.runtime.intent.TargetRef; +import dev.talos.runtime.TurnPolicyTrace; import dev.talos.runtime.task.TaskContract; import dev.talos.runtime.task.TaskContractResolver; import dev.talos.spi.types.ChatMessage; @@ -32,7 +31,6 @@ public static String format(PromptDebugSnapshot snapshot) { } TaskContract contract = TaskContractResolver.fromMessages(snapshot.messages()); - TaskIntent intent = TaskContractResolver.intentFromMessages(snapshot.messages()); String frame = currentTurnFrame(snapshot.messages()); String expectedCoverage = expectedTargetCoverage(contract, frame); String exactCoverage = exactLiteralCoverage(frame); @@ -60,7 +58,7 @@ public static String format(PromptDebugSnapshot snapshot) { .append(", mutationAllowed=").append(contract.mutationAllowed()) .append(", verificationRequired=").append(contract.verificationRequired()).append('\n'); out.append("- ").append(targetLabel(contract)).append(": ").append(joinOrNone(contract)).append('\n'); - out.append("- Target roles: ").append(targetRoles(intent)).append('\n'); + out.append("- Target roles: ").append(targetRoles(contract)).append('\n'); out.append("- ").append(targetCoverageLabel(contract)).append(": ").append(expectedCoverage).append('\n'); out.append("- Exact-literal coverage: ").append(exactCoverage).append("\n\n"); appendContextLedger(out); @@ -184,13 +182,20 @@ private static String joinOrNone(TaskContract contract) { .collect(Collectors.joining(", ")); } - private static String targetRoles(TaskIntent intent) { - if (intent == null || intent.targets().targets().isEmpty()) return "(none)"; - return intent.targets().targets().stream() + private static String targetRoles(TaskContract contract) { + if (contract == null) return "(none)"; + List targets = TurnPolicyTrace.from( + contract, + "unknown", + List.of(), + List.of()) + .rolefulTargets(); + if (targets.isEmpty()) return "(none)"; + return targets.stream() .sorted(Comparator - .comparing((TargetRef target) -> target.path()) - .thenComparing(target -> target.role().name())) - .map(target -> target.path() + " = " + target.role().name()) + .comparing((TurnPolicyTrace.RolefulTarget target) -> target.path()) + .thenComparing(TurnPolicyTrace.RolefulTarget::role)) + .map(target -> target.path() + " = " + target.role()) .collect(Collectors.joining(", ")); } diff --git a/src/main/java/dev/talos/runtime/TurnPolicyTrace.java b/src/main/java/dev/talos/runtime/TurnPolicyTrace.java index 27326d0f..33d56cf0 100644 --- a/src/main/java/dev/talos/runtime/TurnPolicyTrace.java +++ b/src/main/java/dev/talos/runtime/TurnPolicyTrace.java @@ -203,6 +203,17 @@ private static String blankDefault(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; } + private static boolean mutationTargetRole(String role) { + return "MUST_MUTATE".equals(role) || "OUTPUT_DESTINATION".equals(role); + } + + private static String expectedTargetRole(TaskContract contract) { + if (contract != null && !contract.mutationAllowed()) { + return contract.verificationRequired() ? "VERIFY_ONLY" : "MUST_READ"; + } + return "MUST_MUTATE"; + } + private static List rolefulTargetsFrom(TaskIntent intent, TaskContract contract) { LinkedHashMap out = new LinkedHashMap<>(); Set activeExpected = contract == null ? Set.of() : contract.expectedTargets(); @@ -211,9 +222,13 @@ private static List rolefulTargetsFrom(TaskIntent intent, TaskCon for (TargetRef ref : intent.targets().targets()) { if (ref == null) continue; String role = ref.role().name(); - if (("MUST_MUTATE".equals(role) || "OUTPUT_DESTINATION".equals(role)) - && !activeExpected.contains(ref.path())) { - continue; + if (mutationTargetRole(role)) { + if (!activeExpected.contains(ref.path())) { + continue; + } + if (contract != null && !contract.mutationAllowed()) { + continue; + } } if ("FORBIDDEN".equals(role) && !activeForbidden.contains(ref.path())) { continue; @@ -221,11 +236,12 @@ private static List rolefulTargetsFrom(TaskIntent intent, TaskCon out.putIfAbsent(ref.path() + "\u0000" + role, RolefulTarget.from(ref)); } } + String expectedRole = expectedTargetRole(contract); for (String expected : activeExpected.stream().sorted().toList()) { - String key = expected + "\u0000MUST_MUTATE"; + String key = expected + "\u0000" + expectedRole; out.putIfAbsent(key, new RolefulTarget( expected, - "MUST_MUTATE", + expectedRole, "RUNTIME_DEFAULT", "active-contract-projection", "", diff --git a/src/test/java/dev/talos/cli/prompt/PromptDebugInspectorTargetRolesTest.java b/src/test/java/dev/talos/cli/prompt/PromptDebugInspectorTargetRolesTest.java index a87534cb..fab9c492 100644 --- a/src/test/java/dev/talos/cli/prompt/PromptDebugInspectorTargetRolesTest.java +++ b/src/test/java/dev/talos/cli/prompt/PromptDebugInspectorTargetRolesTest.java @@ -8,6 +8,7 @@ import java.time.Instant; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class PromptDebugInspectorTargetRolesTest { @@ -31,4 +32,27 @@ void promptDebugShowsRolefulTargets() { assertTrue(rendered.contains("styles.css = MUST_MUTATE"), rendered); assertTrue(rendered.contains("index.html = VERIFY_ONLY"), rendered); } + + @Test + void promptDebugDoesNotShowReadOnlyTargetHintsAsMustMutate() { + PromptDebugSnapshot snapshot = new PromptDebugSnapshot( + "CHAT_REQUEST", + "ollama", + "gpt-oss:20b", + false, + Instant.parse("2026-05-31T00:00:00Z"), + List.of(ChatMessage.user( + "Check whether scripts.js exists and whether script.js exists. Do not change anything.")), + List.of(), + ChatRequestControls.defaults(), + ""); + + String rendered = PromptDebugInspector.format(snapshot); + + assertTrue(rendered.contains("- Task contract: DIAGNOSE_ONLY, mutationAllowed=false"), rendered); + assertTrue(rendered.contains("scripts.js = MUST_READ"), rendered); + assertTrue(rendered.contains("script.js = MUST_READ"), rendered); + assertFalse(rendered.contains("scripts.js = MUST_MUTATE"), rendered); + assertFalse(rendered.contains("script.js = MUST_MUTATE"), rendered); + } } diff --git a/src/test/java/dev/talos/runtime/trace/LocalTurnTracePolicyTraceTest.java b/src/test/java/dev/talos/runtime/trace/LocalTurnTracePolicyTraceTest.java index 6869eb80..4df5d25d 100644 --- a/src/test/java/dev/talos/runtime/trace/LocalTurnTracePolicyTraceTest.java +++ b/src/test/java/dev/talos/runtime/trace/LocalTurnTracePolicyTraceTest.java @@ -115,6 +115,32 @@ void recordsRolefulTargetEvidenceWhilePreservingLegacyProjection() { && "VERIFY_ONLY".equals(target.role()))); } + @Test + void readOnlyPolicyTraceDoesNotRenderTargetHintsAsMutationObligations() { + beginTrace(); + + TurnPolicyTrace policyTrace = TurnPolicyTrace.from( + TaskContractResolver.fromUserRequest( + "Check whether scripts.js exists and whether script.js exists. Do not change anything."), + "INSPECT", + List.of("talos.read_file"), + List.of("tool_use:read_file")); + + LocalTurnTraceCapture.recordPolicyTrace(policyTrace); + + LocalTurnTrace trace = LocalTurnTraceCapture.complete(); + assertFalse(trace.taskContract().mutationAllowed()); + assertEquals(List.of("script.js", "scripts.js"), trace.taskContract().expectedTargets()); + assertFalse(trace.taskContract().rolefulTargets().stream() + .anyMatch(target -> "MUST_MUTATE".equals(target.role()))); + assertTrue(trace.taskContract().rolefulTargets().stream() + .anyMatch(target -> "script.js".equals(target.path()) + && "MUST_READ".equals(target.role()))); + assertTrue(trace.taskContract().rolefulTargets().stream() + .anyMatch(target -> "scripts.js".equals(target.path()) + && "MUST_READ".equals(target.role()))); + } + @Test void policyTraceRecordingHasDedicatedRecorderOwner() throws Exception { Path capturePath = Path.of("src/main/java/dev/talos/runtime/trace/LocalTurnTraceCapture.java");