@capagents/forgeqe 2.3.3 → 2.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +52 -1
- package/dist/forgeqe.js +936 -891
- package/docs/user-guide.html +63 -2
- package/forgeqe.example.yaml +6 -1
- package/package.json +1 -1
- package/prompts/brain.md +9 -0
- package/py/forgeqe_engine/copilot_gateway.py +164 -14
package/docs/user-guide.html
CHANGED
|
@@ -389,7 +389,7 @@ forgeqe docs --copy ./docs # copy the guide, the architecture page, and the re
|
|
|
389
389
|
<div class="area"><a class="area-name" href="#area-setup"><span class="mono">Setup</span></a><ul class="tiles"><li><a class="tile" href="#cap-test-apps"><b>Test environments and their apps</b><span>Place an order in the shop, then check its invoice in Billing, in UAT</span></a></li><li><a class="tile" href="#cap-generated-code"><b>What ForgeQE wrote</b><span>How much of the code ForgeQE wrote is still there?</span></a></li><li><a class="tile" href="#cap-forgeqe-commands"><b>Every forgeqe command from the shell</b><span>Show me the context budget</span></a></li><li><a class="tile" href="#cap-setup-help"><b>Setup, skills, and documentation</b><span>What can you do?</span></a></li></ul></div>
|
|
390
390
|
<div class="area"><a class="area-name" href="#area-desktop"><span class="mono">Desktop</span></a><ul class="tiles"><li><a class="tile" href="#cap-desktop-apps"><b>Automate Windows desktop apps</b><span>Create an invoice for Contoso in the Billing app and save it as a test</span></a></li></ul></div>
|
|
391
391
|
<div class="area"><a class="area-name" href="#area-mobile"><span class="mono">Mobile</span></a><ul class="tiles"><li><a class="tile" href="#cap-mobile-apps"><b>Automate Android and iOS apps</b><span>Sign in to the courier app and accept the first delivery, and save it as a test</span></a></li></ul></div>
|
|
392
|
-
<div class="area"><a class="area-name" href="#area-operations"><span class="mono">Operations</span></a><ul class="tiles"><li><a class="tile" href="#cap-containers"><b>Services, mocks, workspace and browser in Docker or Podman</b><span>Start the database and the queue in Docker</span></a></li><li><a class="tile" href="#cap-monitoring"><b>Scheduled monitoring with alerts</b><span>Check the login flow every 15 minutes and alert Teams</span></a></li><li><a class="tile" href="#cap-notifications"><b>Teams and email notifications</b><span>Post the results to the QA channel</span></a></li><li><a class="tile" href="#cap-pipelines"><b>CI pipelines</b><span>Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make it green</span></a></li></ul></div>
|
|
392
|
+
<div class="area"><a class="area-name" href="#area-operations"><span class="mono">Operations</span></a><ul class="tiles"><li><a class="tile" href="#cap-containers"><b>Services, mocks, workspace and browser in Docker or Podman</b><span>Start the database and the queue in Docker</span></a></li><li><a class="tile" href="#cap-monitoring"><b>Scheduled monitoring with alerts</b><span>Check the login flow every 15 minutes and alert Teams</span></a></li><li><a class="tile" href="#cap-jobs"><b>Background jobs on a schedule</b><span>Run the release-2.3 pack every weekday at 2am in the background</span></a></li><li><a class="tile" href="#cap-notifications"><b>Teams and email notifications</b><span>Post the results to the QA channel</span></a></li><li><a class="tile" href="#cap-pipelines"><b>CI pipelines</b><span>Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make it green</span></a></li></ul></div>
|
|
393
393
|
<div class="area"><a class="area-name" href="#area-data"><span class="mono">Data</span></a><ul class="tiles"><li><a class="tile" href="#cap-databases"><b>Ask a database in plain language</b><span>Which customers in India have paid orders? Ask the orders database</span></a></li><li><a class="tile" href="#cap-test-data"><b>Test data on demand</b><span>Generate test data: 20 customers with 1 to 3 orders each in the shop database</span></a></li><li><a class="tile" href="#cap-data-flows"><b>Data flows between systems</b><span>Check that customers from the digital database reach the CRM correctly</span></a></li></ul></div>
|
|
394
394
|
<div class="area"><a class="area-name" href="#area-automated-tests"><span class="mono">Automated tests</span></a><ul class="tiles"><li><a class="tile" href="#cap-write-tests"><b>Turn a passed run into an automated test</b><span>Write a Playwright test for that run in ../app</span></a></li><li><a class="tile" href="#cap-heal-tests"><b>Heal failing tests</b><span>Heal the failing tests in ../app</span></a></li><li><a class="tile" href="#cap-find-run-tests"><b>Find and run existing tests</b><span>Run the checkout tests</span></a></li><li><a class="tile" href="#cap-regression-packs"><b>Regression packs for a release</b><span>Build a regression pack for sprint 12 and run it on TestMu</span></a></li><li><a class="tile" href="#cap-flaky-tests"><b>Flaky test detection and quarantine</b><span>Which tests are flaky?</span></a></li></ul></div>
|
|
395
395
|
<div class="area"><a class="area-name" href="#area-pull-requests"><span class="mono">Pull requests</span></a><ul class="tiles"><li><a class="tile" href="#cap-pr-review"><b>Review pull requests</b><span>Review PR 42 and focus on error handling</span></a></li><li><a class="tile" href="#cap-pr-impact"><b>Pull request impact and test selection</b><span>What does PR 42 impact?</span></a></li></ul></div>
|
|
@@ -616,6 +616,7 @@ bun src/index.ts init --global --profile azure-gpt4o
|
|
|
616
616
|
<tr><td><code>Model for agent: main, because copilot failed its live check …</code></td><td>ForgeQE used your other model because Copilot did not answer; the message ends with the fix.</td></tr>
|
|
617
617
|
<tr><td>The model is refused, or not found</td><td>The seat's plan or your organization's Copilot policies do not allow that model: pick one the sign-in listed, or ask a Copilot admin to enable it.</td></tr>
|
|
618
618
|
<tr><td><code>GitHub Copilot runs through the ForgeQE engine, which is not set up</code> (or <code>could not be set up (…)</code>)</td><td>Run <code>forgeqe setup</code>; the reason in brackets says what failed.</td></tr>
|
|
619
|
+
<tr><td>Copilot is slow, or a run waits on it ("Starting the browser…", "working out the first step…")</td><td><code>~/.forgeqe/logs/copilot-gateway.log</code> has a line for each Copilot request: when it started, Copilot's session, when each turn ended, and how long it took. It never holds prompts, replies or tokens. A turn that ends without Copilot saying it is idle (a known Copilot SDK issue) is answered 5 seconds later instead of after the 5-minute limit, and a session the run no longer needs (Stop, an error) is stopped.</td></tr>
|
|
619
620
|
<tr><td><code>CERTIFICATE_VERIFY_FAILED</code> while downloading Copilot's runtime</td><td>Your network inspects HTTPS. Update ForgeQE and run <code>forgeqe setup</code>, then sign in again: it uses the certificates your computer trusts (see <a href="../HOW_TO_USE.md#company-networks">Company networks</a>). If it still fails, run <code>forgeqe doctor</code> and send its output to IT.</td></tr>
|
|
620
621
|
</tbody>
|
|
621
622
|
</table></div>
|
|
@@ -3237,6 +3238,62 @@ forgeqe monitor --once # one round, then exit (cron, sch
|
|
|
3237
3238
|
</section>
|
|
3238
3239
|
</div>
|
|
3239
3240
|
</article>
|
|
3241
|
+
<article class="spec feat" id="cap-jobs">
|
|
3242
|
+
<div class="hd"><span class="mono">Operations</span><h3>Background jobs on a schedule</h3></div>
|
|
3243
|
+
<p class="ds">Runs ForgeQE commands (suites, monitor rounds, regression packs, healing, pull-request tests, browser goals) on a schedule or once in the jobs service, apart from the conversation and after it closes, and watches an existing GitHub Actions or Azure DevOps pipeline (read-only). A failed pipeline run or pack run is investigated: the healing agent works on each failure on a local branch of its own (a fix for review, or an app bug), never pushing. Says how each job goes when asked, shows runs as they end, and sends failures, recoveries and findings to Teams or email.</p>
|
|
3244
|
+
<div class="facts">
|
|
3245
|
+
<div class="fact"><span class="mono">Ask it</span><ul class="asks"><li>Run the release-2.3 pack every weekday at 2am in the background</li><li>Watch the pipeline in the background and investigate failures</li><li>How are my jobs doing?</li></ul></div>
|
|
3246
|
+
<div class="fact"><span class="mono">What it needs</span><ul class="needs"><li><span class="need opt">Optional</span><span>A notification channel: notify.channels in forgeqe.yaml, each a Teams Workflows webhook (in an environment variable) or email</span></li></ul></div>
|
|
3247
|
+
<div class="fact"><span class="mono">Tools</span><p class="tools"><code>jobs</code></p></div>
|
|
3248
|
+
</div>
|
|
3249
|
+
<div class="detail"><span class="mono">In detail</span>
|
|
3250
|
+
<section class="doc" id="doc-how_to_use-the-jobs-service-scheduled-and-long-jobs-apart-from-the-shell">
|
|
3251
|
+
<h4>The jobs service: scheduled and long jobs apart from the shell</h4>
|
|
3252
|
+
<div class="prose"><p><code>forgeqe jobs</code> runs ForgeQE commands on a schedule, or once, in a service of their own, so they keep going while you work on something else in the shell, or with the shell closed. Each run is its own <code>forgeqe</code> process with its own output and session log; you ask how a job is going at any time, and its failures and recoveries go to Teams.</p>
|
|
3253
|
+
<pre><code class="language-bash">forgeqe jobs add nightly-pack --cron "0 2 * * 1-5" -- pack run release-2.3 # weekdays at 02:00, this machine's time
|
|
3254
|
+
forgeqe jobs add smoke --every 30m -- suite checks/smoke.yaml
|
|
3255
|
+
forgeqe jobs add heal-login --now -- heal --focus login # once, now
|
|
3256
|
+
forgeqe jobs # every job: running or how its last run ended, and when it runs next
|
|
3257
|
+
forgeqe jobs status nightly-pack # its last runs, and the last lines of the latest
|
|
3258
|
+
forgeqe jobs logs nightly-pack --tail 100
|
|
3259
|
+
forgeqe jobs run nightly-pack # run it now as well
|
|
3260
|
+
forgeqe jobs stop nightly-pack # stop the run that is going
|
|
3261
|
+
forgeqe jobs disable|enable|remove nightly-pack
|
|
3262
|
+
forgeqe jobs shutdown # stop the service; scheduled jobs wait until it starts again
|
|
3263
|
+
</code></pre>
|
|
3264
|
+
<ul>
|
|
3265
|
+
<li><strong>What a job runs.</strong> One ForgeQE command: <code>suite</code>, <code>monitor</code>, <code>run</code>, <code>heal</code>, <code>pr-tests</code>, <code>pack run</code>, <code>pack triage</code> or <code>pipeline watch</code>, with its arguments after <code>--</code>. Nobody is there to answer a question while a job runs, so a <code>run</code> job is a browser run of the goal (<code>--direct</code>), and a <code>pack run</code> job carries <code>--yes</code>: adding the job is your approval, and the command you agree to shows it. It runs with the project's own <code>forgeqe.yaml</code>, model profile and approvals: a job cannot set <code>--config</code>, <code>--profile</code> or <code>--yes</code>, and nothing else is started (no shell, no other program). A <code>monitor</code> job runs one round each time it is due (<code>--once</code>), on the job's schedule; it sends its own <code>monitor.alerts</code>, so its job is quiet unless you add it with notices on.</li>
|
|
3266
|
+
<li><strong>When.</strong> <code>--every 15m</code> (at least 1m), <code>--cron "0 2 * * 1-5"</code> (minute hour day month weekday, local time), or neither: it runs when asked, and <code>add</code> starts it at once. <code>--now</code> also runs a scheduled job straight away. A run missed while the service was down starts once when it is back. When several are due, the one that has waited longest goes first.</li>
|
|
3267
|
+
<li><strong>How many.</strong> <code>jobs.parallel</code> in <code>forgeqe.yaml</code> (default 2) runs at once over all jobs, and never two runs of one job. A run past <code>--timeout</code> (default 6h, at most 24h) is stopped and marked timed out.</li>
|
|
3268
|
+
<li><strong>The service.</strong> <code>forgeqe jobs add</code> and <code>forgeqe jobs run</code> start it when it is not running, as a process of its own that outlives the command and the shell; <code>forgeqe jobs serve</code> runs it in the foreground (for a container or a system service). It is one per project, on this machine, until you stop it or the machine restarts. It listens on 127.0.0.1 only, with a random token kept in your own ForgeQE folder (<code>~/.forgeqe/jobs/</code>, readable by your user only, never in the project, which others may read), and refuses requests from web pages. A second service for the same project is refused. <code>.forgeqe/jobs/</code> and <code>.forgeqe/pipelines/</code> get their own <code>.gitignore</code>, so runs and logs are never committed.</li>
|
|
3269
|
+
<li><strong>Files.</strong> <code>.forgeqe/jobs/<job>/job.json</code> and, for each of its last 50 runs, <code>runs/<run>/run.json</code> and <code>output.log</code>; <code>.forgeqe/jobs/events.jsonl</code> lists what happened; <code>.forgeqe/jobs/service.log</code> is the service's own output. A run that was going when the service stopped is marked interrupted.</li>
|
|
3270
|
+
<li><strong>Watching a CI pipeline.</strong> <code>forgeqe jobs add ci --every 10m -- pipeline watch --github org/app --workflow ci.yml --branch main</code> (or <code>--ado-project Shop --pipeline 12</code>) looks at the pipeline's latest completed run each time, read-only: it starts, re-runs and changes nothing. A new failed run is triaged like a pack's (the JUnit or TRX files in its artifacts on GitHub, the Test Runs API on Azure DevOps; without results, the job and step that failed), in <code>.forgeqe/reports/pipelines/</code>. The job fails while the latest run is failed, so the team hears once when the pipeline breaks and once when it is green again; a cancelled run changes nothing. <code>forgeqe pipeline watch …</code> on its own does one look. GitHub needs the <code>gh</code> CLI signed in; Azure DevOps the same sign-in as the rest of ForgeQE.</li>
|
|
3271
|
+
<li><strong>Investigating failures.</strong> When a <code>pipeline watch</code> or <code>pack run</code> job fails, the failures that healing can decide (an element not found, an expected value that does not hold; not the environment, flaky tests or broken test code, and nothing while the environment is down) are investigated, at most 3 a run, <code>jobs.parallel</code> at once. Each goes to the healing agent in a git worktree of its own, on a new local branch <code>forgeqe/investigate/<test>-<time>-<n></code>, from the commit the failing run tested when this clone has it (fetched when it can be, else <code>HEAD</code>, which the finding says). The agent re-runs the flow in the browser: when the flow works it heals the test, and the fix is committed on that branch for you to review; when the browser fails at the same step it is an app bug, and no branch is kept. Your checkout, its branch and its files are never touched; nothing is pushed or merged; the worktree is removed at the end. <code>node_modules</code>, <code>.venv</code> and <code>venv</code> from your checkout are linked into the worktree so the tests can run, and never committed. The failure is told at once (<code>job_failed</code>), the findings when they are in (<code>job_investigated</code>); <code>forgeqe jobs status</code> lists them, <code>.forgeqe/jobs/<job>/runs/<run>/investigations.json</code> and <code>investigate.log</code> have the detail, and <code>forgeqe investigate <run folder></code> runs an investigation by hand. <code>--no-investigate</code> turns it off for a job.</li>
|
|
3272
|
+
<li><strong>From the shell.</strong> Ask ForgeQE's agent: "run the release-2.3 pack every weekday at 2am in the background" makes it propose the job, and ForgeQE asks you to agree to its exact command and schedule before adding it (<strong>Add the job</strong> / <strong>Don't add it</strong>). You keep working; when a job's run ends, a line says so in the conversation ("Job nightly-pack failed: …"), and "how are my jobs?" or "show the end of the nightly-pack log" get answers from the service. The agent adds a job only when you ask for one, and never one that runs anything but the commands above.</li>
|
|
3273
|
+
<li><strong>Notices.</strong> With <code>notify:</code> channels set up (section 17), a job that fails sends <code>job_failed</code> once (not every run while it keeps failing), <code>job_recovered</code> when it passes again, and a job that runs only when asked sends <code>job_done</code> when it ends. <code>--no-notify</code> keeps a job quiet.</li>
|
|
3274
|
+
</ul>
|
|
3275
|
+
<pre><code class="language-yaml">jobs:
|
|
3276
|
+
parallel: 2
|
|
3277
|
+
</code></pre>
|
|
3278
|
+
<p><strong>On company machines.</strong> Nothing here needs administrator rights:</p>
|
|
3279
|
+
<div class="tbl"><table>
|
|
3280
|
+
<thead>
|
|
3281
|
+
<tr><th>What</th><th>Needs</th></tr>
|
|
3282
|
+
</thead>
|
|
3283
|
+
<tbody>
|
|
3284
|
+
<tr><td>The jobs service</td><td>Runs as you, in your project and your ForgeQE folder. It listens on 127.0.0.1 only, so no firewall rule is needed. Endpoint security may see a background <code>bun</code> process: it is ForgeQE's own (in its install folder), which IT can allow. Jobs pause while the machine sleeps or is off and a missed run starts once when it is back; for always-on jobs, run <code>forgeqe jobs serve</code> on a VM or build agent under its service manager (systemd, a Windows Task Scheduler task "at log on", which needs no administrator, or a container).</td></tr>
|
|
3285
|
+
<tr><td><code>pipeline watch</code> on GitHub</td><td>The <code>gh</code> CLI signed in (<code>gh auth login</code>) with read access to the repository's Actions. A fine-grained token needs Actions: read and Contents: read; with SAML single sign-on, authorize the token for the organization. GitHub Enterprise Server: <code>--github host/owner/repo</code> and <code>gh auth login --hostname host</code>.</td></tr>
|
|
3286
|
+
<tr><td><code>pipeline watch</code> on Azure DevOps</td><td>The Azure DevOps sign-in ForgeQE already uses (<code>forgeqe auth login azure_devops</code>, or a token in <code>ADO_PAT</code>) with Build: read and Test Management: read. Azure DevOps Server works through its collection URL.</td></tr>
|
|
3287
|
+
<tr><td>Investigating failures</td><td>git, read access to fetch the failing commit, the tests' dependencies installed in your checkout, and a model: a key-based profile, or GitHub Copilot where the organization's policy allows Copilot CLI and the SDK. Branches are local; nothing is pushed.</td></tr>
|
|
3288
|
+
<tr><td>Teams notices</td><td>A Workflows webhook ("post to a channel when a webhook request is received"), or an incoming webhook where the tenant still allows them; where neither is allowed, email.</td></tr>
|
|
3289
|
+
<tr><td>Proxy and inspected HTTPS</td><td>The same as the rest of ForgeQE (section "Company networks"); <code>gh</code> uses <code>HTTPS_PROXY</code>. <code>forgeqe doctor</code> checks the machine and the network.</td></tr>
|
|
3290
|
+
</tbody>
|
|
3291
|
+
</table></div>
|
|
3292
|
+
<p>On Windows, the dependency folders are linked into worktrees as junctions (no administrator or developer mode needed), a stopped run ends at once rather than being asked to finish, and the token file is protected by your user profile's permissions.</p>
|
|
3293
|
+
</div>
|
|
3294
|
+
</section>
|
|
3295
|
+
</div>
|
|
3296
|
+
</article>
|
|
3240
3297
|
<article class="spec feat" id="cap-notifications">
|
|
3241
3298
|
<div class="hd"><span class="mono">Operations</span><h3>Teams and email notifications</h3></div>
|
|
3242
3299
|
<p class="ds">Tells the team in Microsoft Teams channels or by email when a suite fails or passes, when a release readiness verdict is in, and when the domain evaluation falls below its gate, with the failing cases, their bugs, and links to the report and the CI build. Each channel picks its events; the agent also posts a summary to a channel when asked.</p>
|
|
@@ -3272,9 +3329,13 @@ forgeqe monitor --once # one round, then exit (cron, sch
|
|
|
3272
3329
|
<tr><td><code>suite_passed</code></td><td>Every case passed</td><td>The count, how long it took, where it ran</td></tr>
|
|
3273
3330
|
<tr><td><code>release</code></td><td>A release readiness verdict</td><td>GO, GO WITH RISKS, or NO-GO, the counts, every blocker and risk, and the wiki page when published</td></tr>
|
|
3274
3331
|
<tr><td><code>eval_failed</code></td><td><code>forgeqe eval domain --min</code> falls below its gate</td><td>Coverage for each variant and invented ids</td></tr>
|
|
3332
|
+
<tr><td><code>job_failed</code></td><td>A job of the jobs service fails or runs out of time, after passing or for the first time (not again while it keeps failing)</td><td>The job, its command and schedule, how long it ran, its last lines of output, and where its output is</td></tr>
|
|
3333
|
+
<tr><td><code>job_recovered</code></td><td>A job passes again after failing</td><td>The same, for the run that passed</td></tr>
|
|
3334
|
+
<tr><td><code>job_done</code></td><td>A job that runs only when asked ends</td><td>The same, whether it passed or failed</td></tr>
|
|
3335
|
+
<tr><td><code>job_investigated</code></td><td>The investigation of a job's failed run is done</td><td>Each failure: a test out of date with its fix on a branch, an app bug, or not decided, with the agent's reasons</td></tr>
|
|
3275
3336
|
</tbody>
|
|
3276
3337
|
</table></div>
|
|
3277
|
-
<p>A channel without <code>on</code> gets <code>suite_failed</code>, <code>release</code>, and <code>
|
|
3338
|
+
<p>A channel without <code>on</code> gets <code>suite_failed</code>, <code>release</code>, <code>eval_failed</code>, <code>job_failed</code>, <code>job_recovered</code>, and <code>job_investigated</code>. Email adds the failure screenshots. A stopped suite is not announced. Monitoring keeps its own <code>monitor.alerts</code>, with the same cards.</p>
|
|
3278
3339
|
<p>Events are sent from runs nobody is watching: <code>forgeqe suite</code> in a pipeline or on a schedule, <code>forgeqe run</code>, and the MCP server. In the shell, where people try things out, nothing is sent on its own; ask instead ("post the results to the QA channel", "tell the release channel the verdict"), and ForgeQE's agent posts a card with <code>notify</code>.</p>
|
|
3279
3340
|
<p>Links: with <code>report_url</code>, cards open the report where the reports folder is published. In Azure Pipelines and GitHub Actions they also open the build, where the report is an artifact. Without either, the card names the report's path.</p>
|
|
3280
3341
|
<p><strong>Setting up a Teams channel:</strong></p>
|
package/forgeqe.example.yaml
CHANGED
|
@@ -133,7 +133,7 @@ prompts:
|
|
|
133
133
|
# industry would, and add each regulation's obligations a feature touches. Your applicable controls, with their ids,
|
|
134
134
|
# go in .forgeqe/knowledge/compliance.md; agents cite only ids listed there.
|
|
135
135
|
# Teams and email notifications for the team: each channel picks its events (suite_failed, suite_passed, release,
|
|
136
|
-
# eval_failed). The Teams webhook URL is a secret: keep it in the environment, never here. See HOW_TO_USE.md section 17.
|
|
136
|
+
# eval_failed, job_failed, job_recovered, job_done). The Teams webhook URL is a secret: keep it in the environment, never here. See HOW_TO_USE.md section 17.
|
|
137
137
|
# notify:
|
|
138
138
|
# report_url: https://reports.example.com/forgeqe # where .forgeqe/reports is published; cards link each report
|
|
139
139
|
# channels:
|
|
@@ -360,3 +360,8 @@ suite:
|
|
|
360
360
|
# password_env: SMTP_PASSWORD
|
|
361
361
|
# # from: ForgeQE <qa-bot@example.com> # default: the username
|
|
362
362
|
# to: [qa-team@example.com]
|
|
363
|
+
|
|
364
|
+
# forgeqe jobs: ForgeQE commands on a schedule or once, in a service apart from the shell (HOW_TO_USE.md, "The jobs
|
|
365
|
+
# service"). Jobs are added with forgeqe jobs add; this sets how many runs go at once over all jobs.
|
|
366
|
+
# jobs:
|
|
367
|
+
# parallel: 2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.5",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"repository": {
|
package/prompts/brain.md
CHANGED
|
@@ -356,6 +356,15 @@ For REST, GraphQL, SOAP, and gRPC:
|
|
|
356
356
|
- For monitoring that continues after this session, you SHOULD set up forgeqe monitor on a computer that is always on, or forgeqe monitor --once from cron or a scheduled pipeline.
|
|
357
357
|
- That pipeline MUST keep .forgeqe/reports/monitor/state.json between runs (a cache), so that alerts start only on changes.
|
|
358
358
|
|
|
359
|
+
### Background jobs
|
|
360
|
+
- Work that repeats on a schedule, or that takes long while the user works on other things, is a job: jobs add, with a name, the ForgeQE command (suite, monitor, run, heal, pr-tests, pack run, pack triage) and every or cron. A job with neither runs one time, now.
|
|
361
|
+
- ForgeQE asks the user to agree to the job before it is added. You MUST NOT add a job the user did not ask for.
|
|
362
|
+
- After you add a job, you SHOULD continue with the other work. Runs that end come in as notices. jobs status and jobs log tell how a job goes.
|
|
363
|
+
- When the user asks how jobs are going, you MUST call jobs list, or jobs status for one job, and MUST NOT guess.
|
|
364
|
+
- A monitor job runs one round each time. It sends its own monitor alerts.
|
|
365
|
+
- To watch a CI pipeline that the team has, add a job on pipeline watch with --github owner/repo --workflow FILE, or --ado-project NAME --pipeline ID, and --branch. It only reads the pipeline.
|
|
366
|
+
- When a pipeline watch or pack run job fails, ForgeQE investigates the failures on local branches. You MUST report each finding as the job reports it: a fix on a branch for review, an app bug, or not decided. You MUST NOT push, merge or delete these branches unless the user asks.
|
|
367
|
+
|
|
359
368
|
### ForgeQE itself
|
|
360
369
|
- Questions about what ForgeQE can do, such as "can you do API testing?", are questions, not tasks. You MUST answer them, and MUST NOT start the work.
|
|
361
370
|
- The capability list in "This session" is complete, and says what works in this setup. For details, you MUST call capabilities with the ids that fit.
|
|
@@ -24,12 +24,14 @@ from __future__ import annotations
|
|
|
24
24
|
import asyncio
|
|
25
25
|
import hmac
|
|
26
26
|
import json
|
|
27
|
+
import logging
|
|
27
28
|
import os
|
|
28
29
|
import re
|
|
29
30
|
import sys
|
|
30
31
|
import tempfile
|
|
31
32
|
import time
|
|
32
33
|
import uuid
|
|
34
|
+
from logging.handlers import RotatingFileHandler
|
|
33
35
|
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple
|
|
34
36
|
|
|
35
37
|
from .systemcerts import use_system_certificates
|
|
@@ -40,6 +42,10 @@ KEY_ENV = "FORGEQE_COPILOT_GATEWAY_KEY"
|
|
|
40
42
|
MAX_BODY = 32 * 1024 * 1024
|
|
41
43
|
# A screenshot step can take minutes on a slow model; past this the request fails rather than hangs.
|
|
42
44
|
REQUEST_TIMEOUT_S = 300.0
|
|
45
|
+
# After a turn ends with its reply (or a call of the caller's tools), how long Copilot gets to say it is idle. Copilot
|
|
46
|
+
# sometimes never does (github/copilot-sdk#2853), which held a request, and every agent waiting on it, for the full
|
|
47
|
+
# REQUEST_TIMEOUT_S.
|
|
48
|
+
TURN_END_GRACE_S = 5.0
|
|
43
49
|
# A tool name as OpenAI and the Copilot SDK take it.
|
|
44
50
|
TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
45
51
|
MAX_TOOLS = 256
|
|
@@ -53,6 +59,34 @@ class GatewayError(Exception):
|
|
|
53
59
|
self.message = message
|
|
54
60
|
|
|
55
61
|
|
|
62
|
+
class CallerGone(Exception):
|
|
63
|
+
"""The caller closed the connection (the user pressed Stop): its Copilot session is stopped, nothing is answered."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# What each request did and how long it took, for when Copilot is slow or stuck: never prompts, replies or tokens.
|
|
67
|
+
_log = logging.getLogger("forgeqe.copilot_gateway")
|
|
68
|
+
_log.propagate = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def gateway_log(rid: str, text: str) -> None:
|
|
72
|
+
_log.info("%s %s", rid, text)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def open_gateway_log() -> Optional[str]:
|
|
76
|
+
"""~/.forgeqe/logs/copilot-gateway.log (FORGEQE_HOME), kept to three files of 1 MB."""
|
|
77
|
+
home = os.environ.get("FORGEQE_HOME") or os.path.join(os.path.expanduser("~"), ".forgeqe")
|
|
78
|
+
path = os.path.join(home, "logs", "copilot-gateway.log")
|
|
79
|
+
try:
|
|
80
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
81
|
+
handler = RotatingFileHandler(path, maxBytes=1_000_000, backupCount=2, encoding="utf-8")
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
85
|
+
_log.addHandler(handler)
|
|
86
|
+
_log.setLevel(logging.INFO)
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
56
90
|
# ---------------------------------------------------------------------------------------------------------------------
|
|
57
91
|
# OpenAI chat completions → one Copilot session
|
|
58
92
|
|
|
@@ -337,11 +371,37 @@ class Copilot:
|
|
|
337
371
|
})
|
|
338
372
|
return out
|
|
339
373
|
|
|
340
|
-
async def complete(self, req: Dict[str, Any], on_text: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
|
374
|
+
async def complete(self, req: Dict[str, Any], on_text: Optional[Callable[[str], None]] = None, gone: Optional["asyncio.Future[Any]"] = None) -> Dict[str, Any]:
|
|
341
375
|
"""The answer to one request. With on_text, the reply's text goes to it as Copilot writes it (not for a JSON
|
|
342
|
-
reply, whose code fences are removed once it is whole)."""
|
|
376
|
+
reply, whose code fences are removed once it is whole). `gone` completes when the caller hangs up."""
|
|
377
|
+
rid = uuid.uuid4().hex[:8]
|
|
378
|
+
started = time.monotonic()
|
|
379
|
+
gateway_log(rid, f"start model={req['model']} tools={len(req['tools'])} schema={req['schema'] is not None} stream={bool(on_text)}")
|
|
380
|
+
try:
|
|
381
|
+
out = await self._complete(rid, req, on_text, gone)
|
|
382
|
+
except CallerGone:
|
|
383
|
+
gateway_log(rid, f"caller gone after {time.monotonic() - started:.1f}s: its Copilot session was stopped")
|
|
384
|
+
raise
|
|
385
|
+
except GatewayError as e:
|
|
386
|
+
gateway_log(rid, f"error {e.status} after {time.monotonic() - started:.1f}s: {e.message[:300]}")
|
|
387
|
+
raise
|
|
388
|
+
calls = out["choices"][0]["message"].get("tool_calls") or []
|
|
389
|
+
gateway_log(rid, f"done after {time.monotonic() - started:.1f}s" + (f" calls={','.join(c['function']['name'] for c in calls)}" if calls else ""))
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
async def _complete(self, rid: str, req: Dict[str, Any], on_text: Optional[Callable[[str], None]], gone: Optional["asyncio.Future[Any]"]) -> Dict[str, Any]:
|
|
343
393
|
from copilot.generated.rpc import PermissionDecisionReject
|
|
344
|
-
from copilot.session_events import
|
|
394
|
+
from copilot.session_events import (
|
|
395
|
+
AssistantMessageData,
|
|
396
|
+
AssistantMessageDeltaData,
|
|
397
|
+
AssistantTurnEndData,
|
|
398
|
+
AssistantTurnStartData,
|
|
399
|
+
AssistantUsageData,
|
|
400
|
+
SessionErrorData,
|
|
401
|
+
SessionIdleData,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
loop = asyncio.get_running_loop()
|
|
345
405
|
|
|
346
406
|
# The text callback while this request streams; None once it is asked for again without streaming.
|
|
347
407
|
stream: Dict[str, Any] = {"to": None if req["schema"] is not None or req["json_only"] else on_text, "sent": False}
|
|
@@ -349,6 +409,27 @@ class Copilot:
|
|
|
349
409
|
usage = {"input": 0, "output": 0, "cached": 0}
|
|
350
410
|
errors: List[str] = []
|
|
351
411
|
calls: List[Dict[str, Any]] = []
|
|
412
|
+
# The last root reply without tool requests, and the timer that ends the wait when a turn ends without idle.
|
|
413
|
+
turn: Dict[str, Any] = {"final": None, "timer": None, "settled": asyncio.Event()}
|
|
414
|
+
|
|
415
|
+
def on_loop(fn: Callable[[], None]) -> None:
|
|
416
|
+
"""The SDK may call on_event from a thread of its own; timers and events belong to the server's loop."""
|
|
417
|
+
try:
|
|
418
|
+
if asyncio.get_running_loop() is loop:
|
|
419
|
+
fn()
|
|
420
|
+
return
|
|
421
|
+
except RuntimeError:
|
|
422
|
+
pass
|
|
423
|
+
loop.call_soon_threadsafe(fn)
|
|
424
|
+
|
|
425
|
+
def settle_soon() -> None:
|
|
426
|
+
if turn["timer"] is None:
|
|
427
|
+
turn["timer"] = loop.call_later(TURN_END_GRACE_S, turn["settled"].set)
|
|
428
|
+
|
|
429
|
+
def unsettle() -> None:
|
|
430
|
+
if turn["timer"] is not None:
|
|
431
|
+
turn["timer"].cancel()
|
|
432
|
+
turn["timer"] = None
|
|
352
433
|
|
|
353
434
|
def on_event(event: Any) -> None:
|
|
354
435
|
data = getattr(event, "data", None)
|
|
@@ -362,12 +443,25 @@ class Copilot:
|
|
|
362
443
|
elif stream["to"] and isinstance(data, AssistantMessageDeltaData) and data.delta_content and not data.parent_tool_call_id:
|
|
363
444
|
stream["sent"] = True
|
|
364
445
|
stream["to"](data.delta_content)
|
|
446
|
+
elif isinstance(data, AssistantMessageData) and not data.parent_tool_call_id:
|
|
447
|
+
turn["final"] = None if data.tool_requests else event
|
|
448
|
+
elif isinstance(data, AssistantTurnStartData) and not data.parent_tool_call_id:
|
|
449
|
+
on_loop(unsettle)
|
|
450
|
+
elif isinstance(data, AssistantTurnEndData) and not data.parent_tool_call_id:
|
|
451
|
+
gateway_log(rid, "turn ended" + (" with a call of the caller's tools" if calls else " with a reply" if turn["final"] is not None else ""))
|
|
452
|
+
if calls or turn["final"] is not None:
|
|
453
|
+
on_loop(settle_soon)
|
|
454
|
+
elif isinstance(data, SessionIdleData):
|
|
455
|
+
gateway_log(rid, "idle")
|
|
365
456
|
|
|
366
457
|
tried: set = set()
|
|
367
458
|
reply: Any = None
|
|
368
459
|
for _ in range(MAX_TOOL_RETRIES + 2):
|
|
369
460
|
errors.clear()
|
|
370
461
|
calls.clear()
|
|
462
|
+
on_loop(unsettle)
|
|
463
|
+
turn["final"] = None
|
|
464
|
+
turn["settled"] = asyncio.Event()
|
|
371
465
|
names = {t["name"] for t in req["tools"]}
|
|
372
466
|
tools = relay_tools(req["tools"], calls, replaces=self.replaces & names)
|
|
373
467
|
options: Dict[str, Any] = dict(
|
|
@@ -387,15 +481,27 @@ class Copilot:
|
|
|
387
481
|
)
|
|
388
482
|
failure: Optional[str] = None
|
|
389
483
|
session = None
|
|
484
|
+
# A session that did not finish cleanly is stopped before it is detached: detaching alone leaves Copilot
|
|
485
|
+
# working on it in the background.
|
|
486
|
+
clean = False
|
|
390
487
|
try:
|
|
391
488
|
session = await self.client.create_session(**options)
|
|
392
|
-
|
|
489
|
+
gateway_log(rid, f"session {getattr(session, 'session_id', '?')}")
|
|
490
|
+
reply, clean = await self._wait(rid, session, req, turn, gone)
|
|
393
491
|
except TimeoutError:
|
|
394
492
|
raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
|
|
493
|
+
except CallerGone:
|
|
494
|
+
raise
|
|
395
495
|
except Exception as e: # Copilot names a tool it will not take; anything else is reported below.
|
|
396
496
|
failure = str(e)
|
|
397
497
|
finally:
|
|
498
|
+
on_loop(unsettle)
|
|
398
499
|
if session is not None:
|
|
500
|
+
if not clean:
|
|
501
|
+
try:
|
|
502
|
+
await session.abort()
|
|
503
|
+
except Exception:
|
|
504
|
+
pass
|
|
399
505
|
try:
|
|
400
506
|
await session.disconnect()
|
|
401
507
|
except Exception:
|
|
@@ -431,6 +537,33 @@ class Copilot:
|
|
|
431
537
|
return completion(req["model"], content, usage)
|
|
432
538
|
|
|
433
539
|
|
|
540
|
+
async def _wait(self, rid: str, session: Any, req: Dict[str, Any], turn: Dict[str, Any], gone: Optional["asyncio.Future[Any]"]) -> Tuple[Any, bool]:
|
|
541
|
+
"""
|
|
542
|
+
The reply, and whether Copilot finished cleanly. Copilot normally says it is idle; when a turn has ended with a
|
|
543
|
+
reply or a call of the caller's tools and it does not, the reply is taken TURN_END_GRACE_S later. A caller that
|
|
544
|
+
hangs up ends the wait at once.
|
|
545
|
+
"""
|
|
546
|
+
waiter = asyncio.ensure_future(
|
|
547
|
+
session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
|
|
548
|
+
)
|
|
549
|
+
waiter.add_done_callback(lambda t: t.cancelled() or t.exception())
|
|
550
|
+
settled = asyncio.ensure_future(turn["settled"].wait())
|
|
551
|
+
watch = {waiter, settled} | ({gone} if gone is not None else set())
|
|
552
|
+
try:
|
|
553
|
+
done, _ = await asyncio.wait(watch, timeout=REQUEST_TIMEOUT_S + 5, return_when=asyncio.FIRST_COMPLETED)
|
|
554
|
+
finally:
|
|
555
|
+
settled.cancel()
|
|
556
|
+
if waiter in done:
|
|
557
|
+
return waiter.result(), True
|
|
558
|
+
waiter.cancel()
|
|
559
|
+
if gone is not None and gone in done:
|
|
560
|
+
raise CallerGone()
|
|
561
|
+
if settled in done:
|
|
562
|
+
gateway_log(rid, f"the turn ended but Copilot did not say it was idle within {TURN_END_GRACE_S:.0f}s: answered anyway")
|
|
563
|
+
return turn["final"], False
|
|
564
|
+
raise TimeoutError()
|
|
565
|
+
|
|
566
|
+
|
|
434
567
|
# Copilot's own tools that the agent runtime's tools are named like (bash, glob, grep, edit, …). A caller tool with one
|
|
435
568
|
# of these names says it replaces Copilot's: the caller's tool is the one offered, and it runs nothing here.
|
|
436
569
|
BUILTIN_TOOLS = frozenset({"bash", "powershell", "glob", "grep", "view", "edit", "create", "task", "skill", "web_fetch", "think", "update_todo", "report_intent"})
|
|
@@ -566,6 +699,13 @@ class Stream:
|
|
|
566
699
|
self._event({"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
|
|
567
700
|
|
|
568
701
|
|
|
702
|
+
async def caller_gone(reader: asyncio.StreamReader) -> None:
|
|
703
|
+
"""Completes when the caller closes the connection. Its callers (the agent runtime, the engine's HTTP client) never
|
|
704
|
+
half-close, so the end of what it sends is the caller gone."""
|
|
705
|
+
while await reader.read(4096):
|
|
706
|
+
pass
|
|
707
|
+
|
|
708
|
+
|
|
569
709
|
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
|
|
570
710
|
try:
|
|
571
711
|
try:
|
|
@@ -608,16 +748,24 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
|
|
|
608
748
|
writer.write(_error(400, "The request body is not JSON."))
|
|
609
749
|
return
|
|
610
750
|
req = translate(body)
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
751
|
+
gone = asyncio.ensure_future(caller_gone(reader))
|
|
752
|
+
try:
|
|
753
|
+
if req["stream"]:
|
|
754
|
+
stream = Stream(writer, req["model"])
|
|
755
|
+
try:
|
|
756
|
+
await stream.finish(await copilot.complete(req, stream.text, gone=gone))
|
|
757
|
+
except GatewayError as e:
|
|
758
|
+
await stream.fail(e.status, e.message)
|
|
759
|
+
except CallerGone:
|
|
760
|
+
raise
|
|
761
|
+
except Exception as e:
|
|
762
|
+
await stream.fail(502, f"GitHub Copilot: {type(e).__name__}: {e}"[:2000])
|
|
763
|
+
return
|
|
764
|
+
writer.write(_response(200, await copilot.complete(req, gone=gone)))
|
|
765
|
+
finally:
|
|
766
|
+
gone.cancel()
|
|
767
|
+
except CallerGone:
|
|
768
|
+
return
|
|
621
769
|
except GatewayError as e:
|
|
622
770
|
writer.write(_error(e.status, e.message))
|
|
623
771
|
except Exception as e: # The SDK's own errors: report them rather than drop the connection.
|
|
@@ -640,6 +788,7 @@ async def serve(out: TextIO, copilot: Any = None) -> int:
|
|
|
640
788
|
if token.startswith("ghp_"):
|
|
641
789
|
out.write(json.dumps({"error": "GitHub Copilot does not take classic tokens (ghp_): use a fine-grained token with the Copilot Requests permission."}) + "\n")
|
|
642
790
|
return 1
|
|
791
|
+
open_gateway_log()
|
|
643
792
|
try:
|
|
644
793
|
copilot = Copilot(token or None)
|
|
645
794
|
await copilot.start()
|
|
@@ -654,6 +803,7 @@ async def serve(out: TextIO, copilot: Any = None) -> int:
|
|
|
654
803
|
return 1
|
|
655
804
|
server = await asyncio.start_server(lambda r, w: handle(r, w, copilot, key), host="127.0.0.1", port=0, limit=64 * 1024)
|
|
656
805
|
port = server.sockets[0].getsockname()[1]
|
|
806
|
+
gateway_log("gateway", f"listening on 127.0.0.1:{port}")
|
|
657
807
|
out.write(json.dumps({"port": port, "login": getattr(copilot, "login", "")}) + "\n")
|
|
658
808
|
out.flush()
|
|
659
809
|
loop = asyncio.get_running_loop()
|