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
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,21 @@ jobs:
./zig-out/bin/zag knowledge
./zig-out/bin/zagd verify

- name: Write the conformance statement
run: ./zig-out/bin/zag evidence
- name: Write the conformance statement, and check it matches what is committed
run: |
./zig-out/bin/zag evidence
# The statement records when it was produced, so that field differs on
# every run. Everything else must not: the results, the subjects and
# the hashes of the artefacts they are about are what the committed
# file is for. Without this check the file could sit in the repository
# claiming passes for code that had changed underneath it.
strip_time() { sed 's/"created_at":"[^"]*"/"created_at":""/g'; }
strip_time < evidence/conformance.jsonl > /tmp/produced.jsonl
git show HEAD:evidence/conformance.jsonl | strip_time > /tmp/committed.jsonl
if ! diff -u /tmp/committed.jsonl /tmp/produced.jsonl; then
echo "The conformance statement has changed. Run './zig-out/bin/zag evidence' and commit the result."
exit 1
fi

- name: Show the accessibility statement
run: ./zig-out/bin/zag accessibility
Expand Down
51 changes: 51 additions & 0 deletions .workspace/policy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# The policy this workspace enforces.
#
# This file is not read by a model and is never shown to one. Instruction files
# such as AGENTS.md and everything under .workspace/rules/ are requests; this
# file is the answer. Changing it is how permissions change. Nothing an agent
# writes or reads can widen it.
#
# Run "zag policy" to see this in words, and "zag check" to have it verified.

id = "zag-workbench"
name = "Work on this repository, ask a model on this computer"
description = "An agent working here may read and change files in this repository and run the build and the tests. It may use a model running on this computer. It may not reach anything else over the network, may not send commits anywhere, and may not use a saved credential."

[[rule]]
id = "read-and-change-this-repository"
allow = ["fs.read", "fs.write", "git.read"]
paths = ["/home/user/zag"]
because = "The work of this workspace is this repository."

[[rule]]
id = "run-the-build-and-the-tests"
allow = ["process.execute"]
commands = ["zig", "git", "sh"]
because = "A change is not finished until the build and the tests have run."

[[rule]]
id = "deleting-asks-first"
ask = ["fs.delete"]
paths = ["/home/user/zag"]
because = "Deleting a file cannot be undone from the log, so a person decides each time."

[[rule]]
id = "models-on-this-computer"
allow = ["model.infer"]
because = "Asking a model is part of the work here."

[[rule]]
id = "nothing-leaves-this-computer"
allow = ["network.connect"]
hosts = ["localhost", "127.0.0.1", "::1"]
because = "Only a model running on this computer may be reached. To use a hosted provider, add its host here, deliberately."

[[rule]]
id = "no-saved-credentials"
deny = ["credentials.read", "credentials.use"]
because = "Nothing in this workspace needs a saved credential, so none may be spent."

[[rule]]
id = "no-pushing"
deny = ["git.push", "remote.execute", "agent.spawn"]
because = "Sending commits, running things elsewhere and starting other agents are decisions for a person, not for an agent."
17 changes: 14 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ This repository holds zag, a workbench where people and agents do engineering
work in one recorded workspace. This file tells an agent how to work here.

Nothing in this file grants a permission. Permissions come from the policy the
person running the workbench sets. If a line here reads like permission to do
something, it is not, and `zag agents AGENTS.md` will say so.
person running the workbench sets, which lives in `.workspace/policy.toml` and
is never shown to a model. Run `zag policy` to read it. If a line here reads
like permission to do something, it is not, and `zag agents AGENTS.md` will say
so.

## Build and test

Expand Down Expand Up @@ -52,7 +54,16 @@ something, it is not, and `zag agents AGENTS.md` will say so.
-Doptimize=ReleaseFast` before and after a change to a hot path.
- `src/terminal` holds the escape-sequence parser, the screen and the
pseudoterminal.
- `src/ai` holds capabilities, the policy engine, approvals and governance.
- `src/ai` holds capabilities, the policy engine, the workspace policy file,
approvals, the typed executors, the model connectors and governance.
- `src/ai/wire` holds one file per model provider shape. A new provider that
speaks an existing shape is an entry in `src/ai/catalog.zig`, not a new file.
- `src/ai/loop.zig` runs the agent loop, and `src/ai/toolschema.zig` decides
what a model may ask for. A tool added there is a tool a model can reach, so
read the file's opening comment before adding one.
- `src/events/lineage.zig` derives the dependency graph. Anything that answers
"what did this affect" belongs there rather than in `graph.zig`, which is the
causal forest and a different question.
- `src/language` holds the plain-language rules.
- `src/knowledge` and `src/metadata` hold the vocabulary, the workspace
knowledge base and the registry.
Expand Down
60 changes: 50 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ zag is a terminal, a block model, an agent runtime and a standards control plane
in one Zig program. It fetches no package from the network: the whole thing
builds from the Zig standard library.

> Status: the substrate is built and tested. The graphical renderer, the editor
> surfaces and the model providers are not written yet. `zag doctor` prints what
> this build can and cannot do, so the claim never runs ahead of the code.
> Status: the substrate is built and tested, and an agent loop runs against it
> with every tool call decided separately. The graphical renderer and the editor
> surfaces are not written yet, and answers do not stream. `zag doctor` prints
> what this build can and cannot do, so the claim never runs ahead of the code.

## Why it is built this way

Expand All @@ -22,7 +23,9 @@ Here the workspace event stream owns the truth:

- The interface does not own truth. It is a fold over the log.
- Agents do not own truth. They propose typed requests; the log records what
happened.
happened. `zag ask` writes its whole run into the same log a command goes
into, refusals included. An agent run and a shell command read the same way
afterwards.
- The pseudoterminal does not own truth. It produces bytes, which become events.

Everything else follows from that. A block list, a session tab, an execution
Expand All @@ -32,11 +35,11 @@ graph, an audit trail and an agent's context are all views of the same events.

| Part | What it does |
| --- | --- |
| `src/events` | The typed event union, append-only hash chain and immutable content-addressed output store |
| `src/events` | The typed event union, append-only hash chain, immutable content-addressed output store, the causal graph and the derived dependency graph |
| `src/workspace` | Blocks, sessions, workflows, structured history, and the workspace service |
| `src/editor` | The command line as an editing document, with undo that groups by intent |
| `src/terminal` | Escape-sequence parser, screen with scrollback, shell integration, real pseudoterminal, and the recording terminal |
| `src/ai` | Capabilities, the policy engine, approvals, typed tools, risk, impact, transparency, generated cards |
| `src/ai` | Capabilities, the policy engine, the workspace policy file, approvals, typed tools and their executors, kernel-enforced path containment, the model connectors and the gate they pass through, the agent loop, attention selection, risk, impact, transparency, generated cards |
| `src/language` | The plain-language pass, with controlled-English and easy-to-read profiles |
| `src/knowledge` | The concept system, the thesaurus view, the published vocabulary and the workspace knowledge base |
| `src/metadata` | The registry that stops one field having four names |
Expand All @@ -62,6 +65,12 @@ You need Zig 0.16.0.
```
zag term # a shell in a terminal that records what you do
zag doctor # what this build can and cannot do
zag policy # the policy in force, and where it was read from
zag providers # the model connectors, and which credentials are set
zag ask "why did that fail?" # ask a model; it can use tools, one decision each
zag ask --stream "..." # the same, printed as the answer arrives
zag secrets .env # what would be taken out before anything is recorded
zag why .workspace/events.jsonl # what a failure depended on, and what it affected
zag run -- zig build test # run a command and record it as a block
zag lint README.md # check text against the plain-language rules
zag terms workspace # what a word means here
Expand All @@ -79,6 +88,11 @@ zag objects --root . # audit stored command output without changing it
zag recover --root . # inspect a damaged log and print a recovery plan
```

Settings live in `.workspace/settings.toml`. `zag emit` writes an example with
every value at its default. A setting this build cannot read is reported by
name, with what was found and what is being used instead, and the terminal
opens anyway — a mistake in that file never costs you a shell.

`zagd` holds a workspace open for clients, remote people and background agents:

```
Expand Down Expand Up @@ -131,15 +145,41 @@ request -> plan -> typed tool request -> policy decision
- There is no free-form shell tool. Each request names one capability and one
resource.
- A path is normalised before policy matching, so textual `..` traversal is
rejected. Event logs, objects and recovery evidence also reject a symbolic
link in the final path component. This is not a filesystem sandbox:
beneath-only execution through a workspace directory handle is still on the
roadmap.
rejected. Beyond that, the executors open every file through a workspace
directory handle with `RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`, so the
kernel refuses a symbolic link that leaves the workspace rather than the
program noticing afterwards. Event logs, objects and recovery evidence also
reject a symbolic link in the final path component.
- A command runs from its argument vector. There is no shell between the two,
so there is nothing for a quotation mark to escape into.
- An executor spends the decision it was handed. It re-derives the capability
and the resource from the request and refuses unless both match, so a decision
to read one file cannot be spent on another.
- Asking a model is three permissions, not one: using a model, reaching that
host, and spending that credential. All three are decided before the request
is encoded, so a refused request is never built and a refused credential is
never read.
- A model cannot name a capability. Its tool call becomes a typed request or
nothing at all, and the capability comes from the request kind. Text can make
a model ask for anything; asking is all it can do.
- Two tools are never offered to a model: asking another model, which would
spend a credential on a request nobody read, and starting an agent, which
would let it widen its own reach.
- The policy is a file the person writes, in `.workspace/policy.toml`. It is
never shown to a model and never consulted by one. Run `zag policy` to read it
in words. A file that does not parse allows nothing; nothing more permissive
is substituted for it.
- An operation that cannot be undone stops and asks, in words that name the
operation and its consequence.
- A repository instruction file shapes behaviour. It can never grant a
capability, and the checker reports any line that tries.

## If something here is wrong

`docs/if-something-is-wrong.md` is for anyone affected by work done with zag,
whether or not they use it. It says how to read the record, how to raise a
problem, and what this project can and cannot do about it.

## Reading further

- `docs/architecture.md` — the event graph, the block model and the control plane
Expand Down
154 changes: 154 additions & 0 deletions bench/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,57 @@ fn benchmarkBlockFold(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerro
return measure.into("blocks folded from the log", "blocks", folded, 0);
}

/// The same query, with the index in front of it.
///
/// Both numbers are reported so the pair can be compared on one machine, which
/// is the only comparison that means anything. The index is built once here
/// because that is how a long-lived surface would hold it; the command-line
/// tool builds one per query and pays for it, which is honest at these sizes
/// and would not be at a hundred times them.
fn benchmarkIndexedQuery(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result {
const commands: usize = 20_000;
const log = try buildCommandLog(arena, commands);
const index = try zag.workspace.block.Index.build(arena, log);
const lookup = try zag.workspace.index.Lookup.build(arena, index);
const now = try zag.core.time.Timestamp.parseIso("2026-09-30T09:00:00Z");
const parsed = try zag.workspace.history.parse(arena, "status:failed zig build");

var measure: Measure = .{};
var matched: usize = 0;
for (0..runs) |_| {
const clock = Clock.start(io);
const results = try zag.workspace.history.run(arena, index, parsed.query, .{
.now = now,
.limit = 20,
.lookup = &lookup,
});
measure.record(clock.elapsed());
matched += results.matchCount;
}
if (matched == 0) return error.QueryMatchedNothing;
return measure.into("the same query, through the index", "queries", 1, 0);
}

/// Building the index itself, which the command-line tool pays for every run.
fn benchmarkIndexBuild(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result {
const commands: usize = 20_000;
const log = try buildCommandLog(arena, commands);
const index = try zag.workspace.block.Index.build(arena, log);

var measure: Measure = .{};
var tokens: usize = 0;
for (0..runs) |_| {
var scratch = std.heap.ArenaAllocator.init(arena);
defer scratch.deinit();
const clock = Clock.start(io);
const lookup = try zag.workspace.index.Lookup.build(scratch.allocator(), index);
measure.record(clock.elapsed());
tokens += lookup.tokenCount();
}
std.mem.doNotOptimizeAway(tokens);
return measure.into("block index built over 20k blocks", "blocks", commands, 0);
}

fn benchmarkHistoryQuery(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result {
const commands: usize = 20_000;
const log = try buildCommandLog(arena, commands);
Expand Down Expand Up @@ -354,6 +405,104 @@ fn benchmarkPlainLanguage(arena: std.mem.Allocator, io: std.Io, runs: usize) any
return measure.into("prose through the language rules", "bytes", text.items.len, text.items.len);
}

fn benchmarkLineIndex(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result {
// A large document, the size a checker or an editor really opens.
var text: std.ArrayList(u8) = .empty;
while (text.items.len < (4 << 20)) {
try text.appendSlice(arena, "a line of ordinary prose, about sixty characters long here\n");
}

var measure: Measure = .{};
for (0..runs) |_| {
var scratch = std.heap.ArenaAllocator.init(arena);
defer scratch.deinit();
const clock = Clock.start(io);
const index = try zag.language.text.LineIndex.build(scratch.allocator(), text.items);
measure.record(clock.elapsed());
std.mem.doNotOptimizeAway(index.starts.len);
}
return measure.into("line breaks found in a document", "bytes", text.items.len, text.items.len);
}

/// Grouping a turn's tool calls into waves.
///
/// The scheduler runs once per turn, before any work is done, so it has to be
/// cheap enough that a turn asking for sixteen calls does not pay for the
/// planning. Measured on a turn of the widest shape the budget allows, mixing
/// reads that overlap with writes and a barrier in the middle.
fn benchmarkSchedule(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result {
const turns: usize = 20_000;
const width: usize = 16;

// The same shape every turn: a barrier in the middle, some reads that share
// a directory with a write, and some that do not.
const claims = try arena.alloc(?zag.ai.schedule.Claim, width);
for (claims, 0..) |*slot, index| {
slot.* = switch (index % 8) {
0 => .everything,
1, 2 => .{ .on = .{
.resource = .{ .path = try std.fmt.allocPrint(arena, "src/shared-{d}", .{index % 3}) },
.access = .write,
} },
else => .{ .on = .{
.resource = .{ .path = try std.fmt.allocPrint(arena, "src/file-{d}.zig", .{index}) },
.access = .read,
} },
};
}

var measure: Measure = .{};
for (0..runs) |_| {
var scratch = std.heap.ArenaAllocator.init(arena);
defer scratch.deinit();
const scratch_arena = scratch.allocator();
const clock = Clock.start(io);
var planned: usize = 0;
for (0..turns) |_| {
const waves = try zag.ai.schedule.plan(scratch_arena, claims);
planned += waves.len;
}
measure.record(clock.elapsed());
std.mem.doNotOptimizeAway(planned);
}
return measure.into("tool-call scheduling", "turns", turns, 0);
}

fn benchmarkLineage(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result {
// A day of recorded work: commands, their output, and the files they
// touched, with the files shared between them so the graph has real edges.
const commands: usize = 20_000;
var log = try buildCommandLog(arena, commands);
var gen: zag.core.id.Generator = .init(9, 1_788_000_000_000);
const actor: zag.events.event.Actor = .{
.id = gen.next(zag.core.id.ActorId),
.kind = .agent,
.label = "agent",
};
const session = gen.next(zag.core.id.SessionId);
var index: usize = 0;
while (index < commands) : (index += 1) {
_ = try log.append(.{ .file_changed = .{
.session = session,
.path = try std.fmt.allocPrint(arena, "src/file-{d}.zig", .{index % 500}),
.changeKind = .modified,
} }, .{ .at = .{ .ns = 1_788_000_000_000_000_000 + @as(i64, @intCast(index)) }, .actor = actor });
}

var measure: Measure = .{};
for (0..runs) |_| {
var scratch = std.heap.ArenaAllocator.init(arena);
defer scratch.deinit();
const clock = Clock.start(io);
const dag = try zag.events.lineage.Dag.build(scratch.allocator(), log);
const path = try dag.criticalPath();
measure.record(clock.elapsed());
std.mem.doNotOptimizeAway(path.items.len);
}
const events = log.entries.items.len;
return measure.into("dependency graph built and its critical path", "events", events, 0);
}

pub fn main(init: std.process.Init) !u8 {
const gpa = init.gpa;
const io = init.io;
Expand Down Expand Up @@ -392,6 +541,11 @@ pub fn main(init: std.process.Init) !u8 {
.{ .run = benchmarkHistoryQuery, .times = 20 },
.{ .run = benchmarkEditor, .times = runs },
.{ .run = benchmarkPlainLanguage, .times = runs },
.{ .run = benchmarkLineIndex, .times = runs },
.{ .run = benchmarkLineage, .times = runs },
.{ .run = benchmarkSchedule, .times = runs },
.{ .run = benchmarkIndexBuild, .times = runs },
.{ .run = benchmarkIndexedQuery, .times = runs * 4 },
};
for (benchmarks) |benchmark| {
var scratch = std.heap.ArenaAllocator.init(gpa);
Expand Down
Loading