Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The initial proof gates are:

| Code | Condition |
| --- | --- |
| `GS1101` | duplicate declaration or entry, including limits, tool arguments, and model context keys |
| `GS1204` | unsupported duration unit |
| `GS2002` | tool call without a declared capability |
| `GS2003` | failure outside the workflow failure set |
Expand All @@ -26,6 +27,14 @@ The initial proof gates are:

When adding or intentionally changing a diagnostic, update the smallest relevant `.guard` fixture and its manifest entry in the same pull request.

## Duplicate entries

Each limit name, named tool argument, and model context key may appear only
once in its block or call. A repeated key produces `GS1101` at that key's
source location before its value can replace the earlier entry. This applies
even when both values are identical. Unique entries may be reordered, and
names may be reused in separate calls, context blocks, or workflow limits.

## Comparison operands

Ordering operators (`<`, `<=`, `>`, `>=`) require matching numeric or
Expand Down
13 changes: 13 additions & 0 deletions fixtures/diagnostics/duplicate-cost.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
enum Failure { ASSERT DURATION COST }
record Input { text: String }
workflow Example(input: Input) -> Input fails Failure {
capabilities {}
limits {
tool_calls <= 0
model_calls <= 0
duration <= 1s else DURATION
cost <= 0 USD else COST
cost <= 1 USD else COST
}
return input
}
13 changes: 13 additions & 0 deletions fixtures/diagnostics/duplicate-duration.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
enum Failure { ASSERT DURATION COST }
record Input { text: String }
workflow Example(input: Input) -> Input fails Failure {
capabilities {}
limits {
tool_calls <= 0
model_calls <= 0
duration <= 1s else DURATION
duration <= 2s else DURATION
cost <= 0 USD else COST
}
return input
}
13 changes: 13 additions & 0 deletions fixtures/diagnostics/duplicate-model-calls.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
enum Failure { ASSERT DURATION COST }
record Input { text: String }
workflow Example(input: Input) -> Input fails Failure {
capabilities {}
limits {
tool_calls <= 0
model_calls <= 0
model_calls <= 1
duration <= 1s else DURATION
cost <= 0 USD else COST
}
return input
}
21 changes: 21 additions & 0 deletions fixtures/diagnostics/duplicate-model-context.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
enum Failure { ERROR INVALID DURATION COST }
record Input { text: String }
workflow Example(input: Input) -> Input fails Failure {
capabilities {}
limits {
tool_calls <= 0
model_calls <= 1
duration <= 1s else DURATION
cost <= 0 USD else COST
}
result = generate Input using model("test") {
instructions: "Return the input."
context: {
text: input.text
text: "overridden"
}
}
on error => fail ERROR
on invalid => fail INVALID
return result
}
17 changes: 17 additions & 0 deletions fixtures/diagnostics/duplicate-tool-argument.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
enum Failure { DENIED TIMEOUT ERROR INVALID DURATION COST }
record Input { text: String }
tool echo(text: String) -> Input
workflow Example(input: Input) -> Input fails Failure {
capabilities { echo else DENIED }
limits {
tool_calls <= 1
model_calls <= 0
duration <= 1s else DURATION
cost <= 0 USD else COST
}
result = call echo(text: input.text, text: "overridden")
on timeout => fail TIMEOUT
on error => fail ERROR
on invalid => fail INVALID
return result
}
13 changes: 13 additions & 0 deletions fixtures/diagnostics/duplicate-tool-calls.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
enum Failure { ASSERT DURATION COST }
record Input { text: String }
workflow Example(input: Input) -> Input fails Failure {
capabilities {}
limits {
tool_calls <= 0
tool_calls <= 1
model_calls <= 0
duration <= 1s else DURATION
cost <= 0 USD else COST
}
return input
}
138 changes: 138 additions & 0 deletions fixtures/diagnostics/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,144 @@
}
}
]
},
{
"id": "duplicate-tool-calls",
"file": "duplicate-tool-calls.guard",
"diagnostics": [
{
"code": "GS1101",
"severity": "error",
"message": "Duplicate limit 'tool_calls'",
"range": {
"start": {
"offset": 177,
"line": 7,
"column": 5
},
"end": {
"offset": 187,
"line": 7,
"column": 15
}
}
}
]
},
{
"id": "duplicate-model-calls",
"file": "duplicate-model-calls.guard",
"diagnostics": [
{
"code": "GS1101",
"severity": "error",
"message": "Duplicate limit 'model_calls'",
"range": {
"start": {
"offset": 198,
"line": 8,
"column": 5
},
"end": {
"offset": 209,
"line": 8,
"column": 16
}
}
}
]
},
{
"id": "duplicate-duration",
"file": "duplicate-duration.guard",
"diagnostics": [
{
"code": "GS1101",
"severity": "error",
"message": "Duplicate limit 'duration'",
"range": {
"start": {
"offset": 231,
"line": 9,
"column": 5
},
"end": {
"offset": 239,
"line": 9,
"column": 13
}
}
}
]
},
{
"id": "duplicate-cost",
"file": "duplicate-cost.guard",
"diagnostics": [
{
"code": "GS1101",
"severity": "error",
"message": "Duplicate limit 'cost'",
"range": {
"start": {
"offset": 259,
"line": 10,
"column": 5
},
"end": {
"offset": 263,
"line": 10,
"column": 9
}
}
}
]
},
{
"id": "duplicate-tool-argument",
"file": "duplicate-tool-argument.guard",
"diagnostics": [
{
"code": "GS1101",
"severity": "error",
"message": "Duplicate tool argument 'text'",
"range": {
"start": {
"offset": 371,
"line": 12,
"column": 40
},
"end": {
"offset": 375,
"line": 12,
"column": 44
}
}
}
]
},
{
"id": "duplicate-model-context",
"file": "duplicate-model-context.guard",
"diagnostics": [
{
"code": "GS1101",
"severity": "error",
"message": "Duplicate model context key 'text'",
"range": {
"start": {
"offset": 396,
"line": 15,
"column": 7
},
"end": {
"offset": 400,
"line": 15,
"column": 11
}
}
}
]
}
]
}
9 changes: 9 additions & 0 deletions packages/guardstep/src/compiler/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,16 @@ export class Parser {

private parseLimits(): WorkflowLimits {
this.consumeValue("{", "Expected '{' after limits");
const names = new Set<string>();
let toolCalls: number | undefined;
let modelCalls: number | undefined;
let duration: WorkflowLimits["duration"] | undefined;
let cost: WorkflowLimits["cost"] | undefined;

while (!this.checkValue("}")) {
const limit = this.consumeKind("identifier", "Expected limit name");
if (names.has(limit.value)) this.fail(limit, "GS1101", `Duplicate limit '${limit.value}'`);
names.add(limit.value);
this.consumeValue("<=", "Expected '<=' after limit name");
const maximum = Number(this.consumeKind("number", "Expected numeric limit").value);
if (limit.value === "tool_calls") toolCalls = maximum;
Expand Down Expand Up @@ -213,8 +216,11 @@ export class Parser {
const tool = this.parseQualifiedName();
this.consumeValue("(", "Expected '(' after tool name");
const argumentsMap: Record<string, Expression> = {};
const names = new Set<string>();
while (!this.checkValue(")")) {
const name = this.consumeKind("identifier", "Expected tool argument name");
if (names.has(name.value)) this.fail(name, "GS1101", `Duplicate tool argument '${name.value}'`);
names.add(name.value);
this.consumeValue(":", "Expected ':' after tool argument name");
argumentsMap[name.value] = this.parseExpression();
if (!this.matchValue(",")) break;
Expand Down Expand Up @@ -264,8 +270,11 @@ export class Parser {
this.consumeValue(":", "Expected ':' after context");
this.consumeValue("{", "Expected '{' before model context");
const context: Record<string, Expression> = {};
const names = new Set<string>();
while (!this.checkValue("}")) {
const name = this.consumeKind("identifier", "Expected context field name");
if (names.has(name.value)) this.fail(name, "GS1101", `Duplicate model context key '${name.value}'`);
names.add(name.value);
this.consumeValue(":", "Expected ':' after context field name");
context[name.value] = this.parseExpression();
this.matchValue(",");
Expand Down
71 changes: 71 additions & 0 deletions packages/guardstep/src/test/duplicate-entries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";

import { compileSource } from "../compiler/index.js";

const source = `enum Failure { DENIED TIMEOUT ERROR INVALID DURATION COST }
record Input { text: String }
tool echo(first: String, second: String) -> Input
workflow Example(input: Input) -> Input fails Failure {
capabilities { echo else DENIED }
limits {
cost <= 0 USD else COST
duration <= 1s else DURATION
model_calls <= 2
tool_calls <= 2
}
first = call echo(second: "suffix", first: input.text)
on timeout => fail TIMEOUT
on error => fail ERROR
on invalid => fail INVALID
second = call echo(first: first.text, second: "another")
on timeout => fail TIMEOUT
on error => fail ERROR
on invalid => fail INVALID
generated = generate Input using model("test") {
instructions: "Return the input."
context: { second: second.text, first: first.text }
}
on error => fail ERROR
on invalid => fail INVALID
result = generate Input using model("test") {
instructions: "Return the input."
context: { first: generated.text second: second.text }
}
on error => fail ERROR
on invalid => fail INVALID
return result
}
`;

test("unique limits may be reordered and repeated in another workflow", () => {
const ir = compileSource({
source: source + source.slice(source.indexOf("workflow")).replace("workflow Example", "workflow Another"),
sourcePath: "unique.guard",
});
assert.equal(ir.workflows.length, 2);
for (const workflow of ir.workflows) {
assert.deepEqual(workflow.limits, {
tool_calls: 2,
model_calls: 2,
duration: { maximum_ms: 1000, error: "DURATION" },
cost: { maximum: 0, currency: "USD", error: "COST" },
});
}
});

test("unique argument and context keys retain source order and may recur in other steps", () => {
const ir = compileSource({ source, sourcePath: "unique.guard" });
const [first, second, generated, result] = ir.workflows[0]!.steps;
assert.ok(first?.kind === "tool" && second?.kind === "tool");
assert.deepEqual(Object.keys(first.arguments), ["second", "first"]);
assert.deepEqual(Object.keys(second.arguments), ["first", "second"]);
assert.deepEqual(first.arguments.second, { kind: "literal", value: "suffix" });
assert.deepEqual(second.arguments.second, { kind: "literal", value: "another" });
assert.ok(generated?.kind === "model" && result?.kind === "model");
assert.deepEqual(Object.keys(generated.context), ["second", "first"]);
assert.deepEqual(Object.keys(result.context), ["first", "second"]);
assert.deepEqual(result.context.first, {
kind: "member", target: { kind: "identifier", name: "generated" }, property: "text",
});
});