๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ loader.ts ยท 109 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109import { existsSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, basename } from "node:path";
import type { Capability } from "../config.ts";

export interface AgentSpec {
  // Logical name without extension, e.g. "table".
  name: string;
  // File name as referenced in notes, e.g. "table.md".
  file: string;
  // Full markdown contents (system prompt + contract).
  content: string;
  // Capabilities declared in the "## Required capability" section.
  capabilities: Capability[];
  // git blob SHA of the prompt text this spec carries โ€” how a run says which prompt
  // version it used โ€” or
  // null for a spec whose content is a literal in this codebase rather than a file โ€”
  // those are versioned by the application's own commit and have no blob of their own.
  // Computed from `content`, so it is the SHA of what was SENT and not of what a
  // checkout has committed; see `blobSha`.
  sha: string | null;
  // True when this agent lives in tmp/<session>/agents (session-built).
  sessionBuilt: boolean;
}

const CAPABILITY_WORDS: Capability[] = ["text", "vision", "structured_output"];

function parseCapabilities(content: string): Capability[] {
  const m = content.match(/##\s*Required capability\s*\n([^#]*)/i);
  const found = new Set<Capability>();
  if (m) {
    for (const cap of CAPABILITY_WORDS) {
      if (new RegExp(`\\b${cap}\\b`).test(m[1])) found.add(cap);
    }
  }
  if (found.size === 0) found.add("vision");
  return [...found];
}

// The git blob SHA of a prompt, computed from the text rather than asked of git.
//
// The number is identical to `git hash-object <file>` and to the `sha` GitHub's contents API
// returns for the same file, because that is what a blob SHA is: sha1 over the bytes
// `blob <length>\0` and then the content. What it no longer needs is a checkout.
//
// The old answer was `git -C <agentsDir> rev-parse HEAD:./<file>`, and it returns null wherever
// the deployment does not ship a `.git` directory โ€” which is the container Iris runs in. So
// every deployed round on file records `agent_sha: null`, and #349 is what that costs: four
// deployed rounds of the same PDF moved one defect from 0 to 28 pages in 100, and no log among
// them can say which prompt each round ran. A field that is null exactly where the question
// gets asked is not a pinned version.
//
// It also stops being wrong in the one case where the old answer was available: a modified
// working copy RAN its own text, and `HEAD:./<file>` named the committed blob instead โ€” so a
// round measured against an edited prompt was recorded under the SHA of a prompt that did not
// run. `src/tools/calibrate.ts` recovers a session's contract with `git cat-file blob <sha>`
// and already says what it does when the blob is not in this checkout, so an uncommitted prompt
// now announces itself there instead of judging those pages against a contract they were never
// written to.
function blobSha(content: string): string {
  const bytes = Buffer.from(content, "utf8");
  return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
}

// Loads an agent by name, preferring a session-built agent in tmp/ over the
// upstream library: a session-built agent is used for the rest of that
// session. Returns null when no agent exists for the type.
export function loadAgent(
  name: string,
  opts: { agentsDir: string; tmpAgentsDir: string },
): AgentSpec | null {
  const file = name.endsWith(".md") ? name : `${name}.md`;
  const logical = basename(file, ".md");

  const sessionPath = join(opts.tmpAgentsDir, file);
  if (existsSync(sessionPath)) {
    const content = readFileSync(sessionPath, "utf8");
    return {
      name: logical,
      file,
      content,
      capabilities: parseCapabilities(content),
      // A session-built agent has no upstream blob, and it still has a prompt worth
      // identifying: two rounds of one session can run two different builds of the same
      // agent, and grouping their calls needs a key. `agent_content` on the log line carries
      // the whole text for these (store/runlog.ts), so this is a short name for something the
      // log already holds rather than a claim that the file is in a checkout โ€” `sessionBuilt`
      // is what says which.
      sha: blobSha(content),
      sessionBuilt: true,
    };
  }

  const libPath = join(opts.agentsDir, file);
  if (existsSync(libPath)) {
    const content = readFileSync(libPath, "utf8");
    return {
      name: logical,
      file,
      content,
      capabilities: parseCapabilities(content),
      sha: blobSha(content),
      sessionBuilt: false,
    };
  }

  return null;
}