diff --git a/.github/workflows/engine-client.yml b/.github/workflows/engine-client.yml index 7b956fe41e..01c2679eac 100644 --- a/.github/workflows/engine-client.yml +++ b/.github/workflows/engine-client.yml @@ -13,12 +13,9 @@ permissions: jobs: check: - name: engine-client (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # Linux only: the crate has no OS-specific code paths worth a per-OS leg on every PR. + name: engine-client + runs-on: ubuntu-latest defaults: run: working-directory: crates/engine-client diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3966ae5f1a..b259ed458e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,9 @@ name: test on: + # Pull requests run ONE fast linux path: typecheck, core unit tests, the HttpApi gates. + # The long engine suite and the Windows legs run only by hand (Actions -> test -> Run + # workflow), where `changes` always reports heavy=true and every job below runs. workflow_dispatch: # Pull requests only. A merge to `develop` or `main` is a pull request that already # passed, so running again on push would repeat the same checks. @@ -18,64 +21,89 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true -# The engine suite is sharded across JOBS rather than parallelised across workers inside -# one. The suite contains tests whose assertion is "this finished within N milliseconds", -# so their budget is really a statement about machine load: `bun test --parallel` passed on -# a 16-core idle host and failed in CI, where four workers sharing a runner's cores pushed -# a 3s wait past a 5s ceiling. A shard gets a runner to itself, so per-test wall clock stays -# what it was serially and only the total divides. See docs/TEST-PARALLELISM.md. +# Required checks on develop and main: `typecheck`, `core (linux)`, `httpapi (linux)`, +# `unit (linux)`. Keep those job names exactly. A job skipped by `if:` reports as passing, +# so a docs-only or version-only pull request satisfies them without running anything. # -# `unit (linux)` is an aggregator that does no work. It exists so the required status check -# keeps a stable name while the jobs underneath it change shape — renaming a required check -# in the same pull request that introduces it is a deadlock, because the check the rule -# demands never reports. +# The engine suite is sharded across JOBS rather than parallelised across workers inside +# one; see docs/TEST-PARALLELISM.md. It takes ~10 runner-minutes, so it is dispatch-only. jobs: - engine: - name: engine (linux ${{ matrix.shard }}/4) + changes: + name: changes runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - # Every shard reports. Cancelling siblings on the first failure hides how much else - # is broken, which is the thing you want to know from a red run. - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - defaults: - run: - shell: bash + outputs: + heavy: ${{ steps.diff.outputs.heavy }} steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "24" + # The pull request merge commit and its first parent (the base) are enough for a + # two-dot diff; no merge base lookup is needed. + fetch-depth: 2 - - name: Setup Bun - uses: ./.github/actions/setup-bun - - # The ripgrep tests resolve `rg` from PATH, then a cache dir, and only then download - # it from github.com. Relying on that download made tests hang to the harness timeout - # with nothing in the log naming the cause, so it is provisioned up front. - - name: Provision ripgrep + - name: Docs-only or version-only? + id: diff run: | - sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep - rg --version | head -1 + set -euo pipefail + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "heavy=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + heavy=false + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f" in + # Docs. + *.md|docs/*|LICENSE*|CHANGELOG*) continue ;; + # Whole-file version markers. + VERSION|REDROB_VERSION|UPSTREAM_VERSION|*-version.txt|*/VERSION) continue ;; + # Manifests and lockfiles: allowed only if every changed line is a version string. + package.json|*/package.json|bun.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock) + if git diff -U0 HEAD^1 HEAD -- "$f" \ + | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' \ + | grep -vE '^[+-][[:space:]]*("version":[[:space:]]*"[^"]*",?|version[[:space:]]*=[[:space:]]*"[^"]*")[[:space:]]*$' >/dev/null; then + echo "non-version change in $f" + heavy=true + fi + ;; + *) + echo "code change: $f" + heavy=true + ;; + esac + done < <(git diff --name-only HEAD^1 HEAD) + + echo "heavy=$heavy" + echo "heavy=$heavy" >> "$GITHUB_OUTPUT" + + typecheck: + name: typecheck + needs: changes + if: needs.changes.outputs.heavy == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Configure git identity - run: | - git config --global user.email "44862514+savagemanage@users.noreply.github.com" - git config --global user.name "redrob" + - name: Setup Bun + uses: ./.github/actions/setup-bun - - name: Run engine tests - run: ./scripts/test-shard.sh packages/redrob ${{ matrix.shard }} 4 - env: - REDROB_EXPERIMENTAL_DISABLE_FILEWATCHER: "false" + - name: Run typecheck + # Bound turbo's fan-out. tsgo peaks at ~5.3 GB on packages/redrob and + # ~2.4 GB each on core and tui, so turbo's default concurrency of 10 + # co-schedules them and the 16 GB ubuntu-latest runner OOM-kills the + # workers (exit 137 on client/protocol/tui/redrob). tsgo is already + # internally parallel across the 4 vCPUs, so 2 tasks at a time costs no + # wall time: all 30 packages finish in ~43s at either 2 or 3, and 2 + # keeps combined tsgo RSS near 6 GB. + run: bun turbo typecheck --concurrency=2 core: name: core (linux) + needs: changes + if: needs.changes.outputs.heavy == 'true' runs-on: ubuntu-latest timeout-minutes: 20 defaults: @@ -103,8 +131,7 @@ jobs: git config --global user.email "44862514+savagemanage@users.noreply.github.com" git config --global user.name "redrob" - # Not sharded: 933 tests in ~22s. Splitting it would cost more in runner setup than - # it saves. + # Not sharded: 933 tests in ~22s. - name: Run core tests working-directory: packages/core run: bun test --timeout 30000 --only-failures @@ -121,6 +148,8 @@ jobs: httpapi: name: httpapi (linux) + needs: changes + if: needs.changes.outputs.heavy == 'true' runs-on: ubuntu-latest timeout-minutes: 20 defaults: @@ -148,56 +177,91 @@ jobs: git config --global user.email "44862514+savagemanage@users.noreply.github.com" git config --global user.name "redrob" - # Its own job rather than a step after the tests: ~1m38s that has no reason to sit - # behind anything else. - name: Run HttpApi exerciser gates working-directory: packages/redrob run: bun run test:httpapi + # Dispatch only: ~10 runner-minutes across four shards. + engine: + name: engine (linux ${{ matrix.shard }}/4) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + # Every shard reports. Cancelling siblings on the first failure hides how much else + # is broken, which is the thing you want to know from a red run. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + # The ripgrep tests resolve `rg` from PATH, then a cache dir, and only then download + # it from github.com. Relying on that download made tests hang to the harness timeout + # with nothing in the log naming the cause, so it is provisioned up front. + - name: Provision ripgrep + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep + rg --version | head -1 + + - name: Configure git identity + run: | + git config --global user.email "44862514+savagemanage@users.noreply.github.com" + git config --global user.name "redrob" + + - name: Run engine tests + run: ./scripts/test-shard.sh packages/redrob ${{ matrix.shard }} 4 + env: + REDROB_EXPERIMENTAL_DISABLE_FILEWATCHER: "false" + + # `unit (linux)` is an aggregator that does no work. It exists so the required status check + # keeps a stable name while the jobs underneath it change shape. unit: name: unit (linux) - needs: [engine, core, httpapi] + needs: [changes, typecheck, core, httpapi, engine] # `always()` so a failing dependency still reports here rather than leaving the required - # check permanently "expected" — a skipped required check never resolves and blocks the - # pull request with no visible failure to fix. + # check permanently "expected". Skipped dependencies (docs/version-only pull requests, and + # the dispatch-only engine shards on a pull request) count as passing. if: always() runs-on: ubuntu-latest steps: - name: Report run: | set -euo pipefail - printf 'engine %s\ncore %s\nhttpapi %s\n' \ - "${{ needs.engine.result }}" "${{ needs.core.result }}" "${{ needs.httpapi.result }}" - # A matrix dependency reports one aggregated result, so a single red shard makes - # `needs.engine.result` a failure. - for result in "${{ needs.engine.result }}" "${{ needs.core.result }}" "${{ needs.httpapi.result }}"; do - if [ "$result" != "success" ]; then - echo "::error::a linux test job did not succeed" - exit 1 - fi + printf 'changes %s\ntypecheck %s\ncore %s\nhttpapi %s\nengine %s\n' \ + "${{ needs.changes.result }}" "${{ needs.typecheck.result }}" "${{ needs.core.result }}" \ + "${{ needs.httpapi.result }}" "${{ needs.engine.result }}" + for result in "${{ needs.changes.result }}" "${{ needs.typecheck.result }}" "${{ needs.core.result }}" \ + "${{ needs.httpapi.result }}" "${{ needs.engine.result }}"; do + case "$result" in + success|skipped) ;; + *) echo "::error::a linux test job did not succeed"; exit 1 ;; + esac done - echo "all linux test jobs succeeded" + echo "all linux test jobs succeeded or were skipped" + # Dispatch only, advisory. The engine suite takes ~15 minutes across four Windows shards. windows: name: unit (windows ${{ matrix.shard }}/4) + if: github.event_name == 'workflow_dispatch' runs-on: windows-latest timeout-minutes: 25 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] - # Windows runs and reports, but does not block. Two path-normalization tests were failing - # here for a real reason — a drive-less rooted path resolved against the runner's D: - # workspace instead of the C: temp directory — and that is fixed; whether anything else is - # Windows-specific is not yet established, so this stays advisory until it has been green - # for a while. - # - # Sharded now, which the earlier diagnosis said not to do. That diagnosis was wrong: the - # job was not hanging after its tests. Turbo buffers each task's output until the task - # ends, so `core` flushing at 88s and `redrob` flushing 14 minutes later read as a stall. - # The engine suite genuinely takes 906s here against 381s on Linux, which is what nearly - # hit the 20-minute limit. Splitting it is the fix, and it is the same mechanism already - # proven on Linux. continue-on-error: true defaults: run: @@ -231,6 +295,7 @@ jobs: windows-core: name: unit (windows core) + if: github.event_name == 'workflow_dispatch' runs-on: windows-latest timeout-minutes: 25 continue-on-error: true diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index 813b3068e5..0000000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: typecheck - -on: - workflow_dispatch: - pull_request: - -jobs: - typecheck: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run typecheck - # Bound turbo's fan-out. tsgo peaks at ~5.3 GB on packages/redrob and - # ~2.4 GB each on core and tui, so turbo's default concurrency of 10 - # co-schedules them and the 16 GB ubuntu-latest runner OOM-kills the - # workers (exit 137 on client/protocol/tui/redrob). tsgo is already - # internally parallel across the 4 vCPUs, so 2 tasks at a time costs no - # wall time: all 30 packages finish in ~43s at either 2 or 3, and 2 - # keeps combined tsgo RSS near 6 GB. - run: bun turbo typecheck --concurrency=2 diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index 1e2a9471c5..a02626e022 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -91,7 +91,8 @@ keywords: so `홈택스` matches `홈택스에서`. - **urls** match `redrob_context.page_url`. The scheme is ignored, `*` stays inside one path segment, `**` crosses segments, and a trailing `/*` in `urls:` covers the whole - site including its root. + site including its root. In agent sessions the same matcher reads the optional + `pageUrl` field of the session prompt body. - Armed bodies go in as one extra `system` message after the caller's own system messages, labelled with what armed each one. A skill with neither list never arms and behaves as before; in agent sessions an armable skill is left out of the standing diff --git a/packages/docs/favicon.svg b/packages/docs/favicon.svg index 157edc4d75..131fb00ed5 100644 --- a/packages/docs/favicon.svg +++ b/packages/docs/favicon.svg @@ -1,7 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/identity/mark-192x192.png b/packages/identity/mark-192x192.png index 071d18fe0c..1d7ed05ea6 100644 Binary files a/packages/identity/mark-192x192.png and b/packages/identity/mark-192x192.png differ diff --git a/packages/identity/mark-512x512-light.png b/packages/identity/mark-512x512-light.png index 9f602d5ec3..b2933d97ba 100644 Binary files a/packages/identity/mark-512x512-light.png and b/packages/identity/mark-512x512-light.png differ diff --git a/packages/identity/mark-512x512.png b/packages/identity/mark-512x512.png index 48f38fc8c2..3176ea8593 100644 Binary files a/packages/identity/mark-512x512.png and b/packages/identity/mark-512x512.png differ diff --git a/packages/identity/mark-96x96.png b/packages/identity/mark-96x96.png index b635c0759c..734642ffe3 100644 Binary files a/packages/identity/mark-96x96.png and b/packages/identity/mark-96x96.png differ diff --git a/packages/identity/mark-light.svg b/packages/identity/mark-light.svg index ac619f1b2f..de4d9218d3 100644 --- a/packages/identity/mark-light.svg +++ b/packages/identity/mark-light.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/identity/mark.svg b/packages/identity/mark.svg index 157edc4d75..131fb00ed5 100644 --- a/packages/identity/mark.svg +++ b/packages/identity/mark.svg @@ -1,7 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/redrob/src/session/prompt.ts b/packages/redrob/src/session/prompt.ts index 2eb8313f96..86a7c23811 100644 --- a/packages/redrob/src/session/prompt.ts +++ b/packages/redrob/src/session/prompt.ts @@ -698,6 +698,7 @@ const layer = Layer.effect( }, system: input.system, format: input.format, + ...(input.pageUrl?.trim() ? { pageUrl: input.pageUrl.trim() } : {}), } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -1300,18 +1301,18 @@ const layer = Layer.effect( * otherwise arm on half of what the user said. * * The URL is deliberately NOT fetched. Arming by URL glob needs the active - * tab, and the only way to get it is a browser request -- a round trip on - * every single message, which with no extension attached spends the request - * deadline before the model sees anything. A twenty second pause before each - * reply is a worse product than URL arming is a better one. Keywords arm - * today; the URL half arms once a session carries its tab URL as state the - * engine already holds, rather than as a question it has to ask. + * tab, and asking the browser for it is a round trip on every single message, + * which with no extension attached spends the request deadline before the + * model sees anything. Instead the caller states it: `pageUrl` on the prompt + * is stored on the user message, and arms through the same matcher the + * chat-completions route runs on `redrob_context.page_url`. */ const armedSkills = yield* sys.armedSkills(agent, { prompt: (lastUserMsg?.parts ?? []) .filter((part) => part.type === "text") .map((part) => (part.type === "text" ? part.text : "")) .join("\n"), + url: lastUser.pageUrl, }) const system = [ ...env, @@ -1563,6 +1564,10 @@ export const PromptInput = Schema.Struct({ format: Schema.optional(SessionV1.Format), system: Schema.optional(Schema.String), variant: Schema.optional(Schema.String), + pageUrl: Schema.optional(Schema.String).annotate({ + description: + "URL of the caller's active browser tab. Skills whose `urls:` globs match it arm for this turn, as on /v1/chat/completions.", + }), parts: Schema.Array( Schema.Union([ SessionV1.TextPartInput, diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 17e7a2c5aa..062506c964 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -351,6 +351,12 @@ export const User = Schema.Struct({ }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + /** + * The URL of the browser tab the caller was on when it sent this message. Skills whose + * `urls:` globs match it arm for the turn, the same matcher `/v1/chat/completions` runs + * on `redrob_context.page_url`. Absent for callers with no tab. + */ + pageUrl: Schema.optional(Schema.String), }).annotate({ identifier: "UserMessage" }) export type User = Types.DeepMutable> diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 08c950a1db..1d2609c0bb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3871,6 +3871,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts?: Array }, options?: Options, @@ -3891,6 +3892,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "pageUrl" }, { in: "body", key: "parts" }, ], }, @@ -4224,6 +4226,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts?: Array }, options?: Options, @@ -4244,6 +4247,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "pageUrl" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 91f487c01a..5a62723601 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -310,6 +310,7 @@ export type UserMessage = { tools?: { [key: string]: boolean } + pageUrl?: string } export type ProviderAuthError = { @@ -10384,6 +10385,7 @@ export type SessionPromptData = { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts: Array } path: { @@ -10731,6 +10733,7 @@ export type SessionPromptAsyncData = { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts: Array } path: { diff --git a/packages/server/src/handlers/chat-completion.ts b/packages/server/src/handlers/chat-completion.ts index 8d3570005f..a2637170b3 100644 --- a/packages/server/src/handlers/chat-completion.ts +++ b/packages/server/src/handlers/chat-completion.ts @@ -342,20 +342,27 @@ function toJsonBody(id: string, model: string, response: LLMResponse): unknown { finish_reason: toFinishReason(response.finishReason), }, ], - ...(usage - ? { - usage: { - prompt_tokens: usage.inputTokens ?? 0, - completion_tokens: usage.outputTokens ?? 0, - total_tokens: usage.totalTokens ?? 0, - }, - } - : {}), + ...(usage ? { usage: toWireUsage(usage) } : {}), + } +} + +/** The engine's usage, in OpenAI's `usage` shape. One mapping for the JSON body and the stream. */ +function toWireUsage(usage: NonNullable) { + return { + prompt_tokens: usage.inputTokens ?? 0, + completion_tokens: usage.outputTokens ?? 0, + total_tokens: usage.totalTokens ?? 0, } } /** One SSE frame carrying an OpenAI chunk. */ -function chunkFrame(id: string, model: string, delta: unknown, finishReason: string | null): Sse.Event { +function chunkFrame( + id: string, + model: string, + delta: unknown, + finishReason: string | null, + usage?: ReturnType, +): Sse.Event { return { _tag: "Event", event: "message", @@ -366,6 +373,7 @@ function chunkFrame(id: string, model: string, delta: unknown, finishReason: str created: seconds(), model, choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), }), } } @@ -386,13 +394,25 @@ function doneFrame(): Sse.Event { * caller would read a truncated answer as a complete one. */ function toChunks(id: string, model: string, events: Stream.Stream) { + // Usage rides on the finish frame, as OpenAI's own final chunk carries it. A provider that + // reports it only per step still gets counted: the last step's usage stands in. Without this the + // streaming path dropped usage entirely, and the browser's memory runs recorded 0 tokens. + let stepUsage: LLMResponse["usage"] return events.pipe( - Stream.flatMap((event: LLMEvent) => Stream.fromIterable(eventFrames(id, model, event))), + Stream.flatMap((event: LLMEvent) => { + if (event.type === "step-finish" && event.usage !== undefined) stepUsage = event.usage + return Stream.fromIterable(eventFrames(id, model, event, stepUsage)) + }), Stream.concat(Stream.make(doneFrame())), ) } -function eventFrames(id: string, model: string, event: LLMEvent): ReadonlyArray { +function eventFrames( + id: string, + model: string, + event: LLMEvent, + stepUsage?: LLMResponse["usage"], +): ReadonlyArray { switch (event.type) { case "text-delta": return [chunkFrame(id, model, { content: event.text }, null)] @@ -402,8 +422,10 @@ function eventFrames(id: string, model: string, event: LLMEvent): ReadonlyArray< const [call] = toWireToolCalls([{ id: event.id, name: event.name, input: event.input }]) return [chunkFrame(id, model, { tool_calls: [{ index: 0, ...call }] }, null)] } - case "finish": - return [chunkFrame(id, model, {}, toFinishReason(event.reason))] + case "finish": { + const usage = event.usage ?? stepUsage + return [chunkFrame(id, model, {}, toFinishReason(event.reason), usage ? toWireUsage(usage) : undefined)] + } case "provider-error": // Surfaced as data, not as a stream failure: headers are already sent, so // this is the only way the caller learns the turn broke.