From 0d51b5121332093469c8c9e7cd4737db7197cf9a Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 4 Jul 2026 11:09:44 +0800 Subject: [PATCH 1/2] fix(site,browser): store extension ids, snippet overflow, privacy page Site: - Add /privacy page (privacy policy for the Browser Bridge extension store listings) with a footer link. - Fix the install-command snippet overflowing its box on desktop: .hero-install and .cli-install now wrap instead of nowrap, so the copy button stays pinned in its reserved padding. - Showcase: bounce focus off the sandboxed demo iframes so they can't steal focus on load and scroll the whole page to the middle. Extension: - Drop the unused `scripting` permission (store rejection risk) and make the manifest description browser-neutral (Edge flags references to "chrome"). Browser native host: - allowed_origins now lists every id the extension can have (unpacked dev id + Chrome Web Store id), not just the dev id, so a store-installed extension can open the native messaging host. Edge runtime id still to be added. Generated with Jack AI bot --- extension/README.md | 1 - extension/manifest.json | 4 +- internal/browser/discover.go | 18 ++++- internal/browser/nativehost.go | 6 +- internal/browser/nativehost_test.go | 23 ++++-- site/src/App.tsx | 2 + site/src/components/SiteFooter.tsx | 9 ++- site/src/pages/PrivacyPage.tsx | 117 ++++++++++++++++++++++++++++ site/src/pages/ShowcasePage.tsx | 29 +++++++ site/src/pages/cli.css | 2 +- site/src/pages/home.css | 5 +- site/src/pages/legal.css | 71 +++++++++++++++++ site/src/styles/global.css | 11 ++- 13 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 site/src/pages/PrivacyPage.tsx create mode 100644 site/src/pages/legal.css diff --git a/extension/README.md b/extension/README.md index c88f190..5b3b69d 100644 --- a/extension/README.md +++ b/extension/README.md @@ -50,7 +50,6 @@ afterwards the extension reconnects silently — you connect once. Use - `debugger` — the CDP control channel (Chrome shows a banner while attached). - `tabs`, `tabGroups` — create/switch/group tabs. - `storage` — persist the server URL and pairing token. -- `scripting` — reserved for future in-page helpers. - `host_permissions` limited to `127.0.0.1` / `localhost` — it only ever talks to your local jcode. diff --git a/extension/manifest.json b/extension/manifest.json index 6d6f99f..232be47 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,10 +2,10 @@ "manifest_version": 3, "name": "jcode Browser Bridge", "version": "0.1.4", - "description": "Let jcode see and operate this Chrome via the Chrome DevTools Protocol. Connects to your local jcode server.", + "description": "Let jcode see and operate your browser via the DevTools Protocol. Connects to your local jcode server.", "minimum_chrome_version": "116", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0JN3n8PBlNtsaMBRXs5g76Kt8C1VIO5bz+vRY4HMAyn1soIAhNDu9ZAcQjOUmuu1SyJe7A683EfgXJhpFghvSULi63rKHO584FBc9zK53b8m1yVq6HuNZtwXTZyDXeCVNwKstI9zHCLqTUEWyBuy3zJOWRq+0d8h9Moz2a0rDLePqAmPyQb6nlSvDomPIIRnk4p0sBSQbENWKwd/hhJwlsl/D4JK/SVWLXfhQZOP5PceGJ0gnOmIH38bPuxW3l1EWk3nuOZyIVRUvF9QkuAhS9U/+1WEVCco6tijVaBoHI6rzbxouR5BH9Drg0lt9VPJPlq0HlU8AyLLepweJ6MWxwIDAQAB", - "permissions": ["debugger", "tabs", "storage", "tabGroups", "alarms", "scripting", "nativeMessaging"], + "permissions": ["debugger", "tabs", "storage", "tabGroups", "alarms", "nativeMessaging"], "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], "background": { "service_worker": "background.js", diff --git a/internal/browser/discover.go b/internal/browser/discover.go index d61a4bc..4667586 100644 --- a/internal/browser/discover.go +++ b/internal/browser/discover.go @@ -16,10 +16,24 @@ import ( "github.com/cnjack/jcode/internal/config" ) -// ExtensionID is the chrome extension id derived from the committed public key -// ("key" field) in extension/manifest.json — stable across loads and machines. +// ExtensionID is the unpacked/dev extension id, derived from the committed +// public key ("key" field) in extension/manifest.json — stable across loads. const ExtensionID = "ekcnniaefmnhnemnpphikhgfoofnojnd" +// AllowedExtensionIDs is every id the Browser Bridge extension can have: the +// unpacked dev build (above) plus the published store builds, which the stores +// re-sign under their own ids. All of these must appear in the native-host +// manifest's allowed_origins, or a store-installed extension can't open the +// native host (the browser enforces the origin before launching it). +var AllowedExtensionIDs = []string{ + ExtensionID, // unpacked / dev (manifest "key") + "olkapiiikpfhaccmjphakolinkcggcbd", // Chrome Web Store + // Microsoft Edge Add-ons: add the runtime extension id here once known. It is + // NOT the Partner Center product id (0RDCKGMRP90R) — that's the listing id. + // Find the runtime id at edge://extensions after installing, or in Partner + // Center → Extension overview. +} + // FindChrome returns the path to a Chromium-based browser executable, or "" // when none is found. Explicit configPath (config.browser.chrome_path) wins. func FindChrome(configPath string) string { diff --git a/internal/browser/nativehost.go b/internal/browser/nativehost.go index 09213cf..3be807f 100644 --- a/internal/browser/nativehost.go +++ b/internal/browser/nativehost.go @@ -138,12 +138,16 @@ func sendEndpoint(out io.Writer) { // nativeHostManifest is the JSON Chrome/Edge read to find and authorize the host. func nativeHostManifest(binPath string) []byte { + origins := make([]string, len(AllowedExtensionIDs)) + for i, id := range AllowedExtensionIDs { + origins[i] = fmt.Sprintf("chrome-extension://%s/", id) + } m := map[string]any{ "name": NativeHostName, "description": "jcode Browser Bridge native host", "path": binPath, "type": "stdio", - "allowed_origins": []string{fmt.Sprintf("chrome-extension://%s/", ExtensionID)}, + "allowed_origins": origins, } data, _ := json.MarshalIndent(m, "", " ") return data diff --git a/internal/browser/nativehost_test.go b/internal/browser/nativehost_test.go index 69149b2..18fbbb4 100644 --- a/internal/browser/nativehost_test.go +++ b/internal/browser/nativehost_test.go @@ -73,12 +73,23 @@ func TestNativeHostManifestShape(t *testing.T) { t.Errorf("type = %v", m["type"]) } origins, ok := m["allowed_origins"].([]any) - if !ok || len(origins) != 1 { - t.Fatalf("allowed_origins = %v", m["allowed_origins"]) - } - want := "chrome-extension://" + ExtensionID + "/" - if origins[0] != want { - t.Errorf("allowed_origins[0] = %v, want %s", origins[0], want) + if !ok || len(origins) != len(AllowedExtensionIDs) { + t.Fatalf("allowed_origins = %v, want %d entries", m["allowed_origins"], len(AllowedExtensionIDs)) + } + got := make(map[string]bool, len(origins)) + for _, o := range origins { + s, _ := o.(string) + got[s] = true + } + // Every allowed id (dev + published store builds) must be present, in the + // chrome-extension:/// origin form. + for _, id := range AllowedExtensionIDs { + if want := "chrome-extension://" + id + "/"; !got[want] { + t.Errorf("allowed_origins missing %s (have %v)", want, origins) + } + } + if !got["chrome-extension://"+ExtensionID+"/"] { + t.Errorf("dev/unpacked extension id must always be allowed") } } diff --git a/site/src/App.tsx b/site/src/App.tsx index 9808b4f..e95fbe4 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -10,6 +10,7 @@ const ShowcasePage = lazy(() => import('./pages/ShowcasePage')) const ShowcaseProjectPage = lazy(() => import('./pages/ShowcaseProjectPage')) const DocsLayout = lazy(() => import('./pages/docs/DocsLayout')) const DocPage = lazy(() => import('./pages/docs/DocPage')) +const PrivacyPage = lazy(() => import('./pages/PrivacyPage')) function ScrollToTop() { const { pathname } = useLocation() @@ -64,6 +65,7 @@ export default function App() { } /> } /> } /> + } /> }> } /> } /> diff --git a/site/src/components/SiteFooter.tsx b/site/src/components/SiteFooter.tsx index af0c585..f13c84a 100644 --- a/site/src/components/SiteFooter.tsx +++ b/site/src/components/SiteFooter.tsx @@ -48,9 +48,12 @@ export default function SiteFooter() {
© 2026 jcode. Open source, MIT licensed. - - 津ICP备13004281号-4 - + + Privacy + + 津ICP备13004281号-4 + +
diff --git a/site/src/pages/PrivacyPage.tsx b/site/src/pages/PrivacyPage.tsx new file mode 100644 index 0000000..300df6a --- /dev/null +++ b/site/src/pages/PrivacyPage.tsx @@ -0,0 +1,117 @@ +import './legal.css' + +export default function PrivacyPage() { + return ( +
+
+ Privacy +

jcode Browser Bridge — Privacy Policy

+

Last updated: July 4, 2026

+ +

+ jcode Browser Bridge is the optional Chrome/Edge extension for{' '} + jcode, an open-source AI coding agent. The + extension lets a copy of jcode running on your own machine see and + operate your browser through the Chrome DevTools Protocol. This policy explains exactly + what the extension touches and where that data goes. +

+ +
+

+ The short version: the extension only ever talks to a jcode server on + your own computer over a local loopback connection (127.0.0.1 /{' '} + localhost). It sends nothing to us or to any third-party server. There is + no analytics, no tracking, and no advertising. +

+
+ +

What the extension accesses, and why

+
    +
  • + Debugger (CDP) — to relay commands and events for the tabs jcode + controls. Chrome shows its “{`is being debugged`}” banner the whole time a + tab is attached; detaching (or clicking Cancel on that banner) hands control + back and jcode stops. +
  • +
  • + Tabs & tab groups — to open, switch and group the tabs under agent + control. Controlled tabs are placed in a visible “jcode 🔎” tab group so you + can always see which tabs are being driven. +
  • +
  • + Native messaging — to discover the jcode app running on your machine + (even on a dynamic local port) and obtain its local server URL and a pairing token. +
  • +
  • + Storage — to keep the local server URL and pairing token in{' '} + chrome.storage.local so you only have to connect once. +
  • +
  • + Host access limited to loopback — the extension’s host + permissions are restricted to http://127.0.0.1/* and{' '} + http://localhost/*. It can only reach a jcode server on your own machine. +
  • +
+ +

Data it handles and where it goes

+
    +
  • + Pairing token & server URL — generated by your local jcode app and + stored in chrome.storage.local. Used only to open the local WebSocket + connection to that app. Never transmitted anywhere else. +
  • +
  • + Page content, DOM and screenshots — when the agent inspects or acts on + a page, that content is relayed between the browser tab and your local jcode app. The + extension does not store it and does not send it to any remote or third-party server. +
  • +
+ +
+

+ One honest caveat. The jcode application on your machine is a + “bring your own model” tool: to do the work you ask, it may send the page + content it reads to the AI model provider you have configured. That + transmission is performed by the jcode app under your control and your provider’s + terms — not by this extension. The extension itself only moves data between your browser + and your local jcode app. +

+
+ +

What the extension does not do

+
    +
  • It does not send any data to servers operated by us.
  • +
  • It does not include analytics, telemetry, trackers, or advertising.
  • +
  • It does not sell or share your data with anyone.
  • +
  • It does not connect to any host other than your local jcode server.
  • +
+ +

Retention and your control

+

+ The pairing token and server URL persist in chrome.storage.local until you + remove them. Use Disconnect in the extension popup to revoke the token + and detach every controlled tab, or remove the extension to erase all of its stored data. +

+ +

Contact

+

+ Questions about this policy or the extension can be raised on our{' '} + + GitHub issue tracker + + . The extension’s source is part of the open-source jcode project at{' '} + + github.com/cnjack/jcode + + . +

+ +

Changes to this policy

+

+ If the extension’s data handling changes, this page will be updated and the + “Last updated” date above will change accordingly. +

+
+
+ ) +} diff --git a/site/src/pages/ShowcasePage.tsx b/site/src/pages/ShowcasePage.tsx index c9a2789..da12b72 100644 --- a/site/src/pages/ShowcasePage.tsx +++ b/site/src/pages/ShowcasePage.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { Link } from 'react-router-dom' import Reveal from '../components/Reveal' import { CHALLENGES, SPRINTS, type ShowcaseEntry } from '../data/showcase' @@ -40,6 +41,34 @@ function ChallengeCard({ p, i }: { p: ShowcaseEntry; i: number }) { } export default function ShowcasePage() { + // A sandboxed demo iframe calls focus() on load (for keyboard control), which + // pins the whole page scrolled down to it. Focus leaving the host into an + // iframe fires window 'blur'; when it does, drop the iframe's focus and + // restore the user's scroll position (0 until they actually scroll). + useEffect(() => { + let userY = 0 + const track = () => { + userY = window.scrollY + } + const onBlur = () => { + requestAnimationFrame(() => { + const a = document.activeElement as HTMLElement | null + if (a?.tagName === 'IFRAME') { + a.blur() + window.scrollTo(0, userY) + } + }) + } + window.addEventListener('wheel', track, { passive: true }) + window.addEventListener('touchmove', track, { passive: true }) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('wheel', track) + window.removeEventListener('touchmove', track) + window.removeEventListener('blur', onBlur) + } + }, []) + return (
diff --git a/site/src/pages/cli.css b/site/src/pages/cli.css index 3a0a894..750343c 100644 --- a/site/src/pages/cli.css +++ b/site/src/pages/cli.css @@ -17,7 +17,7 @@ } .cli-hero h1 em { font-style: normal; color: var(--accent); } .cli-hero-sub { font-size: 18px; color: var(--navy-ink-soft); max-width: 600px; } -.cli-install { margin: 24px 0 20px; max-width: 560px; padding-right: 84px; white-space: nowrap; border: 1px solid var(--navy-3); } +.cli-install { margin: 24px 0 20px; max-width: 560px; padding-right: 84px; white-space: normal; overflow-wrap: anywhere; border: 1px solid var(--navy-3); } .cli-hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } .btn-ghost-dark { border: 1.5px solid var(--navy-line); diff --git a/site/src/pages/home.css b/site/src/pages/home.css index 99197a4..9872b85 100644 --- a/site/src/pages/home.css +++ b/site/src/pages/home.css @@ -41,7 +41,10 @@ margin: 26px 0 22px; max-width: 560px; padding-right: 84px; - white-space: nowrap; + /* Wrap long commands (the copy button sits in the reserved right padding) — + nowrap made the install command overflow the box. */ + white-space: normal; + overflow-wrap: anywhere; } .hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } .hero-note { diff --git a/site/src/pages/legal.css b/site/src/pages/legal.css new file mode 100644 index 0000000..c02562e --- /dev/null +++ b/site/src/pages/legal.css @@ -0,0 +1,71 @@ +.legal-page { + padding-top: calc(var(--nav-h) + 48px); + padding-bottom: 96px; +} + +.legal { + max-width: 760px; +} + +.legal h1 { + font-family: var(--font-display); + font-size: clamp(30px, 5vw, 44px); + margin: 18px 0 6px; +} + +.legal .legal-updated { + color: var(--ink-faint); + font-family: var(--font-mono); + font-size: 13px; + margin-bottom: 36px; +} + +.legal h2 { + font-size: 20px; + margin: 40px 0 12px; +} + +.legal p, +.legal li { + color: var(--ink-soft); + font-size: 15.5px; + line-height: 1.75; +} + +.legal p { + margin: 12px 0; +} + +.legal ul { + margin: 12px 0; + padding-left: 22px; +} + +.legal li { + margin: 8px 0; +} + +.legal strong { + color: var(--ink); +} + +.legal code { + font-family: var(--font-mono); + font-size: 0.9em; + background: var(--paper-3); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 1px 6px; +} + +.legal .legal-note { + border-left: 3px solid var(--accent); + background: var(--accent-soft); + border-radius: 0 var(--r-md) var(--r-md) 0; + padding: 14px 18px; + margin: 20px 0; +} + +.legal .legal-note p { + margin: 0; +} diff --git a/site/src/styles/global.css b/site/src/styles/global.css index f2b6a61..93a3dd4 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -358,6 +358,7 @@ code, kbd, pre { font-family: var(--font-mono); } font-size: 13px; } .footer-bottom a { color: var(--navy-ink-soft); } +.footer-legal { display: inline-flex; gap: 20px; align-items: center; } @media (max-width: 780px) { .footer-grid { grid-template-columns: 1fr 1fr; padding: 44px 0 28px; } } @@ -393,8 +394,14 @@ code, kbd, pre { font-family: var(--font-mono); } font-family: var(--font-mono); font-size: 13.5px; line-height: 1.7; - padding: 18px 20px; - overflow-x: auto; + /* Right padding leaves room for the absolutely-positioned copy button. */ + padding: 18px 56px 18px 20px; + /* Wrap long commands instead of scrolling. The copy button is an absolute + child, so an overflow-x:auto scroll area here would carry the button off + to the side as the user scrolled — pinning it requires a non-scrolling + positioned parent. */ + white-space: normal; + overflow-wrap: anywhere; box-shadow: var(--shadow-soft); } .snippet .prompt-char { color: var(--accent); user-select: none; } From 2deb48cfd488cbbd8f0259fda5b7e683277e8b5c Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 4 Jul 2026 11:26:51 +0800 Subject: [PATCH 2/2] refactor(agent): migrate tracing to interface-based ChatModelAgentMiddleware eino v0.9.9 deprecated adk.AgentMiddleware (struct-based) in favour of ChatModelAgentMiddleware (interface-based Handlers); staticcheck SA1019 was failing CI on main. Migrate the Langfuse tracer and every call site: - langfuse.go: replace the AgentMiddleware struct (BeforeChatModel / AfterChatModel / WrapToolCall) with a langfuseMiddleware type that embeds adk.BaseChatModelAgentMiddleware and overrides BeforeModelRewriteState / AfterModelRewriteState / WrapInvokableToolCall. Behaviour (model-generation and tool-call spans, sub-span context) is preserved. - agent.go / interactive.go / web.go / subagent.go / team/manager.go: thread []adk.ChatModelAgentMiddleware and pass tracing via Handlers (outermost) instead of the deprecated Middlewares field. Drops a //nolint:staticcheck workaround in web.go. golangci-lint now reports 0 issues; go build/vet and tests pass. Generated with Jack AI bot --- internal/agent/agent.go | 17 +- internal/command/interactive.go | 2 +- internal/command/web.go | 2 +- internal/team/manager.go | 10 +- internal/telemetry/langfuse.go | 276 +++++++++++++++++--------------- internal/tools/subagent.go | 4 +- 6 files changed, 161 insertions(+), 150 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1473059..3c993e8 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -15,11 +15,10 @@ const maxIterations = 1000 type ApprovalFunc func(ctx context.Context, toolName, toolArgs string) (bool, error) -// NewAgent creates a ChatModelAgent with the following middleware stack +// NewAgent creates a ChatModelAgent with the following handler stack // (outermost to innermost): // -// Middlewares (old-style): [langfuse] -// Handlers (new-style): [...caller handlers, approval+safeTool] +// Handlers: [langfuse tracing, ...caller handlers, approval+safeTool] // // ModelRetryConfig is always enabled (3 retries with default exponential backoff). func NewAgent( @@ -28,12 +27,15 @@ func NewAgent( tools []tool.BaseTool, instruction string, approvalFunc ApprovalFunc, - middlewares []adk.AgentMiddleware, + middlewares []adk.ChatModelAgentMiddleware, handlers []adk.ChatModelAgentMiddleware, ) (*adk.ChatModelAgent, error) { - // Approval + safe-tool-error middleware is always the innermost handler - // so that summarization/reduction see the raw tool output first. - enhanced := append(append([]adk.ChatModelAgentMiddleware{}, handlers...), newApprovalMiddleware(approvalFunc)) + // Handler order is outermost → innermost: tracing middlewares first, then the + // caller's handlers, then approval + safe-tool-error innermost so that + // summarization/reduction see the raw tool output first. + enhanced := append([]adk.ChatModelAgentMiddleware{}, middlewares...) + enhanced = append(enhanced, handlers...) + enhanced = append(enhanced, newApprovalMiddleware(approvalFunc)) return adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ Name: "coding", @@ -46,7 +48,6 @@ func NewAgent( }, }, MaxIterations: maxIterations, - Middlewares: middlewares, Handlers: enhanced, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 5, diff --git a/internal/command/interactive.go b/internal/command/interactive.go index 9cb584d..8cd9cb3 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -153,7 +153,7 @@ func (s *interactiveState) subagentTokenFn(totalTokens int64) { } func (s *interactiveState) createAgent() (*adk.ChatModelAgent, error) { - var middlewares []adk.AgentMiddleware + var middlewares []adk.ChatModelAgentMiddleware if s.langfuseTracer != nil { middlewares = append(middlewares, s.langfuseTracer.AgentMiddleware()) } diff --git a/internal/command/web.go b/internal/command/web.go index 920e916..abcff18 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -520,7 +520,7 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err _ = os.MkdirAll(reductionRoot, 0o755) makeAgent := func(cm model.ToolCallingChatModel, ctxLimit int, planMode bool) (*adk.ChatModelAgent, error) { - var middlewares []adk.AgentMiddleware //nolint:staticcheck // langfuseTracer.AgentMiddleware()/agent.NewAgent still use the deprecated type + var middlewares []adk.ChatModelAgentMiddleware if langfuseTracer != nil { middlewares = append(middlewares, langfuseTracer.AgentMiddleware()) } diff --git a/internal/team/manager.go b/internal/team/manager.go index 0f4a434..e0e35a1 100644 --- a/internal/team/manager.go +++ b/internal/team/manager.go @@ -568,15 +568,14 @@ func (m *Manager) runAgentTurn(ctx context.Context, state *TeammateState) (strin return "", fmt.Errorf("invalid chat model type") } - var middlewares []adk.AgentMiddleware + var handlers []adk.ChatModelAgentMiddleware + // Langfuse child trace (outermost) so teammate spans nest under the parent. if m.deps.Tracer != nil { ctx = m.deps.Tracer.WithChildTrace(ctx, fmt.Sprintf("teammate-%s", state.Identity.AgentName)) - middlewares = append(middlewares, m.deps.Tracer.ChildAgentMiddleware()) + handlers = append(handlers, m.deps.Tracer.ChildAgentMiddleware()) } - - var handlers []adk.ChatModelAgentMiddleware if m.deps.HandlersFactory != nil { - handlers = m.deps.HandlersFactory(state.Identity.AgentName, state.Identity.Color) + handlers = append(handlers, m.deps.HandlersFactory(state.Identity.AgentName, state.Identity.Color)...) } ag, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ @@ -590,7 +589,6 @@ func (m *Manager) runAgentTurn(ctx context.Context, state *TeammateState) (strin }, }, MaxIterations: teammateMaxIter, - Middlewares: middlewares, Handlers: handlers, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 3, diff --git a/internal/telemetry/langfuse.go b/internal/telemetry/langfuse.go index d0fc145..61d0eb0 100644 --- a/internal/telemetry/langfuse.go +++ b/internal/telemetry/langfuse.go @@ -7,7 +7,7 @@ import ( langfuseacl "github.com/cloudwego/eino-ext/libs/acl/langfuse" "github.com/cloudwego/eino/adk" - "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/schema" "github.com/cnjack/jcode/internal/config" @@ -117,158 +117,170 @@ func (t *LangfuseTracer) EndChildTrace(ctx context.Context, output string) { }) } -// ChildAgentMiddleware returns an adk.AgentMiddleware for child agents (subagents/teammates). -// It nests generations and tool spans under the parent span stored in context. -func (t *LangfuseTracer) ChildAgentMiddleware() adk.AgentMiddleware { - return t.buildMiddleware(true) +// ChildAgentMiddleware returns a ChatModelAgentMiddleware for child agents +// (subagents/teammates). It nests generations and tool spans under the parent +// span stored in context. +func (t *LangfuseTracer) ChildAgentMiddleware() adk.ChatModelAgentMiddleware { + return &langfuseMiddleware{tracer: t, useParentSpan: true} } -// AgentMiddleware returns an adk.AgentMiddleware that records model generations -// and tool-call spans to Langfuse, keyed by the traceID stored in the context. -func (t *LangfuseTracer) AgentMiddleware() adk.AgentMiddleware { - return t.buildMiddleware(false) +// AgentMiddleware returns a ChatModelAgentMiddleware that records model +// generations and tool-call spans to Langfuse, keyed by the traceID stored in +// the context. +func (t *LangfuseTracer) AgentMiddleware() adk.ChatModelAgentMiddleware { + return &langfuseMiddleware{tracer: t, useParentSpan: false} } -func (t *LangfuseTracer) buildMiddleware(useParentSpan bool) adk.AgentMiddleware { - return adk.AgentMiddleware{ - BeforeChatModel: func(ctx context.Context, state *adk.ChatModelAgentState) error { - traceID, _ := ctx.Value(traceIDKey).(string) - if traceID == "" { - return nil +// langfuseMiddleware implements adk.ChatModelAgentMiddleware. It embeds the adk +// base handler so only the hooks it needs are overridden (model generation +// spans + tool-call spans); every other interface method is a no-op default. +type langfuseMiddleware struct { + *adk.BaseChatModelAgentMiddleware + tracer *LangfuseTracer + useParentSpan bool +} + +// BeforeModelRewriteState opens a Langfuse generation span for the model call. +func (mw *langfuseMiddleware) BeforeModelRewriteState(ctx context.Context, state *adk.ChatModelAgentState, _ *adk.ModelContext) (context.Context, *adk.ChatModelAgentState, error) { + t := mw.tracer + traceID, _ := ctx.Value(traceIDKey).(string) + if traceID == "" { + return ctx, state, nil + } + parentObsID := "" + if mw.useParentSpan { + parentObsID, _ = ctx.Value(parentSpanIDKey).(string) + } + genID, _ := t.client.CreateGeneration(&langfuseacl.GenerationEventBody{ + BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ + BaseEventBody: langfuseacl.BaseEventBody{Name: "chat_model"}, + TraceID: traceID, + ParentObservationID: parentObsID, + StartTime: time.Now(), + }, + InMessages: state.Messages, + }) + _ = adk.SetRunLocalValue(ctx, "langfuse_gen_id", genID) + return ctx, state, nil +} + +// AfterModelRewriteState closes the generation span and records token usage. +func (mw *langfuseMiddleware) AfterModelRewriteState(ctx context.Context, state *adk.ChatModelAgentState, _ *adk.ModelContext) (context.Context, *adk.ChatModelAgentState, error) { + t := mw.tracer + genID := "" + if val, found, err := adk.GetRunLocalValue(ctx, "langfuse_gen_id"); err == nil && found { + genID, _ = val.(string) + } + if genID == "" { + return ctx, state, nil + } + _ = adk.DeleteRunLocalValue(ctx, "langfuse_gen_id") + // Find the last assistant message to record as output. + var outMsg *schema.Message + for i := len(state.Messages) - 1; i >= 0; i-- { + if state.Messages[i].Role == schema.Assistant { + outMsg = state.Messages[i] + break + } + } + + // Read per-call token usage from the context-local TokenUsage tracker. + var usage *langfuseacl.Usage + var metadata map[string]string + if tracker := internalmodel.TokenTrackerFromContext(ctx); tracker != nil { + if d := tracker.GetLastDetail(); d != nil && d.TotalTokens > 0 { + usage = &langfuseacl.Usage{ + PromptTokens: d.PromptTokens, + CompletionTokens: d.CompletionTokens, + TotalTokens: d.TotalTokens, } + metadata = map[string]string{ + "cached_tokens": fmt.Sprintf("%d", d.CachedTokens), + "reasoning_tokens": fmt.Sprintf("%d", d.ReasoningTokens), + } + } + } + + _ = t.client.EndGeneration(&langfuseacl.GenerationEventBody{ + BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ + BaseEventBody: langfuseacl.BaseEventBody{ + ID: genID, + MetaData: metadata, + }, + }, + OutMessage: outMsg, + EndTime: time.Now(), + Usage: usage, + }) + return ctx, state, nil +} + +// WrapInvokableToolCall wraps a tool's execution in a Langfuse span and exposes +// a sub-span creator in context for downstream middleware (e.g. approval). +func (mw *langfuseMiddleware) WrapInvokableToolCall(_ context.Context, endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) (adk.InvokableToolCallEndpoint, error) { + t := mw.tracer + toolName := "" + if tCtx != nil { + toolName = tCtx.Name + } + return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { + traceID, _ := ctx.Value(traceIDKey).(string) + start := time.Now() + var spanID string + if traceID != "" { parentObsID := "" - if useParentSpan { + if mw.useParentSpan { parentObsID, _ = ctx.Value(parentSpanIDKey).(string) } - genID, _ := t.client.CreateGeneration(&langfuseacl.GenerationEventBody{ + spanID, _ = t.client.CreateSpan(&langfuseacl.SpanEventBody{ BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ - BaseEventBody: langfuseacl.BaseEventBody{Name: "chat_model"}, + BaseEventBody: langfuseacl.BaseEventBody{Name: toolName}, TraceID: traceID, ParentObservationID: parentObsID, - StartTime: time.Now(), + Input: argumentsInJSON, + StartTime: start, }, - InMessages: state.Messages, }) - _ = adk.SetRunLocalValue(ctx, "langfuse_gen_id", genID) - return nil - }, + } - AfterChatModel: func(ctx context.Context, state *adk.ChatModelAgentState) error { - genID := "" - if val, found, err := adk.GetRunLocalValue(ctx, "langfuse_gen_id"); err == nil && found { - genID, _ = val.(string) - } - if genID == "" { - return nil - } - _ = adk.DeleteRunLocalValue(ctx, "langfuse_gen_id") - // Find the last assistant message to record as output. - var outMsg *schema.Message - for i := len(state.Messages) - 1; i >= 0; i-- { - if state.Messages[i].Role == schema.Assistant { - outMsg = state.Messages[i] - break - } - } - - // Read per-call token usage from the context-local TokenUsage tracker. - var usage *langfuseacl.Usage - var metadata map[string]string - if tracker := internalmodel.TokenTrackerFromContext(ctx); tracker != nil { - if d := tracker.GetLastDetail(); d != nil && d.TotalTokens > 0 { - usage = &langfuseacl.Usage{ - PromptTokens: d.PromptTokens, - CompletionTokens: d.CompletionTokens, - TotalTokens: d.TotalTokens, - } - metadata = map[string]string{ - "cached_tokens": fmt.Sprintf("%d", d.CachedTokens), - "reasoning_tokens": fmt.Sprintf("%d", d.ReasoningTokens), - } - } - } - - _ = t.client.EndGeneration(&langfuseacl.GenerationEventBody{ - BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ - BaseEventBody: langfuseacl.BaseEventBody{ - ID: genID, - MetaData: metadata, + // Store a sub-span creator in context so downstream middleware + // (e.g. approval) can create child spans under this tool span. + if spanID != "" { + subSpanFunc := SubSpanFunc(func(name string) func(output string) { + childStart := time.Now() + childID, _ := t.client.CreateSpan(&langfuseacl.SpanEventBody{ + BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ + BaseEventBody: langfuseacl.BaseEventBody{Name: name}, + TraceID: traceID, + ParentObservationID: spanID, + StartTime: childStart, }, - }, - OutMessage: outMsg, - EndTime: time.Now(), - Usage: usage, - }) - return nil - }, - - WrapToolCall: compose.ToolMiddleware{ - Invokable: func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { - return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { - traceID, _ := ctx.Value(traceIDKey).(string) - start := time.Now() - var spanID string - if traceID != "" { - parentObsID := "" - if useParentSpan { - parentObsID, _ = ctx.Value(parentSpanIDKey).(string) - } - spanID, _ = t.client.CreateSpan(&langfuseacl.SpanEventBody{ - BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ - BaseEventBody: langfuseacl.BaseEventBody{Name: input.Name}, - TraceID: traceID, - ParentObservationID: parentObsID, - Input: input.Arguments, - StartTime: start, - }, - }) - } - - // Store a sub-span creator in context so downstream middleware - // (e.g. approval) can create child spans under this tool span. - if spanID != "" { - subSpanFunc := SubSpanFunc(func(name string) func(output string) { - childStart := time.Now() - childID, _ := t.client.CreateSpan(&langfuseacl.SpanEventBody{ - BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ - BaseEventBody: langfuseacl.BaseEventBody{Name: name}, - TraceID: traceID, - ParentObservationID: spanID, - StartTime: childStart, - }, - }) - return func(output string) { - if childID != "" { - _ = t.client.EndSpan(&langfuseacl.SpanEventBody{ - BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ - BaseEventBody: langfuseacl.BaseEventBody{ID: childID}, - Output: output, - }, - EndTime: time.Now(), - }) - } - } - }) - ctx = context.WithValue(ctx, toolSpanTracerKey, subSpanFunc) - } - - out, err := next(ctx, input) - if spanID != "" { - output := "" - if out != nil { - output = out.Result - } + }) + return func(output string) { + if childID != "" { _ = t.client.EndSpan(&langfuseacl.SpanEventBody{ BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ - BaseEventBody: langfuseacl.BaseEventBody{ID: spanID}, + BaseEventBody: langfuseacl.BaseEventBody{ID: childID}, Output: output, }, EndTime: time.Now(), }) } - return out, err } - }, - }, - } + }) + ctx = context.WithValue(ctx, toolSpanTracerKey, subSpanFunc) + } + + out, err := endpoint(ctx, argumentsInJSON, opts...) + if spanID != "" { + _ = t.client.EndSpan(&langfuseacl.SpanEventBody{ + BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ + BaseEventBody: langfuseacl.BaseEventBody{ID: spanID}, + Output: out, + }, + EndTime: time.Now(), + }) + } + return out, err + }, nil } diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index 43ec159..1c964a2 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -153,7 +153,7 @@ func (s *subagentTool) InvokableRun(ctx context.Context, argumentsInJSON string, prompt := subagentSystemPrompt(agentType, s.env.Pwd(), s.env.platform) // Inject Langfuse child trace so subagent spans nest under the parent. - var middlewares []adk.AgentMiddleware + var middlewares []adk.ChatModelAgentMiddleware if s.deps.Tracer != nil { runCtx = s.deps.Tracer.WithChildTrace(runCtx, fmt.Sprintf("subagent-%s", input.Name)) middlewares = append(middlewares, s.deps.Tracer.ChildAgentMiddleware()) @@ -170,7 +170,7 @@ func (s *subagentTool) InvokableRun(ctx context.Context, argumentsInJSON string, }, }, MaxIterations: subagentMaxIter, - Middlewares: middlewares, + Handlers: middlewares, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 3, IsRetryAble: internalmodel.IsRetryable,