diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000000..08dfe5c787
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,15 @@
+#!/usr/bin/env sh
+
+# Delegate to pre-commit which runs configured hooks (e.g. gitleaks) from .pre-commit-config.yaml
+if command -v pre-commit >/dev/null 2>&1; then
+ pre-commit run --hook-stage pre-commit
+else
+ echo "pre-commit not found. Install with one of the following:"
+ echo " pip install pre-commit # cross-platform"
+ echo " brew install pre-commit # macOS/Linux with Homebrew"
+ echo ""
+ echo "gitleaks is also required. Install with:"
+ echo " brew install gitleaks # macOS/Linux with Homebrew"
+ echo " or see https://github.com/gitleaks/gitleaks#installation for other options"
+ exit 1
+fi
\ No newline at end of file
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 0000000000..8aa51d15bc
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1,12 @@
+#!/usr/bin/env sh
+
+# Delegate to pre-commit which runs pre-push hooks (e.g. gitleaks) from .pre-commit-config.yaml
+if command -v pre-commit >/dev/null 2>&1; then
+ pre-commit run --hook-stage pre-push
+else
+ echo "pre-commit not found. Install with one of the following methods:"
+ echo " pip install pre-commit gitleaks"
+ echo " brew install pre-commit gitleaks"
+ echo " (see https://pre-commit.com/#installation and https://github.com/gitleaks/gitleaks#installation for more options)"
+ exit 1
+fi
diff --git a/api/oss/src/core/services/v0.py b/api/oss/src/core/services/v0.py
index 0c25956a28..06597bd632 100644
--- a/api/oss/src/core/services/v0.py
+++ b/api/oss/src/core/services/v0.py
@@ -91,17 +91,27 @@ def _format_with_template(
elif format == "curly":
import re
+ # Extract variables that exist in the original template before replacement
+ # This allows us to distinguish template variables from {{}} in user input values
+ original_variables = set(re.findall(r"\{\{(.*?)\}\}", content))
+
result = content
for key, value in kwargs.items():
pattern = r"\{\{" + re.escape(key) + r"\}\}"
old_result = result
- result = re.sub(pattern, str(value), result)
+ # Escape backslashes in the replacement string to prevent regex interpretation
+ escaped_value = str(value).replace("\\", "\\\\")
+ result = re.sub(pattern, escaped_value, result)
+
+ # Only check if ORIGINAL template variables remain unreplaced
+ # Don't error on {{}} that came from user input values
+ unreplaced_matches = set(re.findall(r"\{\{(.*?)\}\}", result))
+ truly_unreplaced = original_variables & unreplaced_matches
- unreplaced_matches = re.findall(r"\{\{(.*?)\}\}", result)
- if unreplaced_matches:
- log.info(f"WORKFLOW Found unreplaced variables: {unreplaced_matches}")
+ if truly_unreplaced:
+ log.info(f"WORKFLOW Found unreplaced variables: {truly_unreplaced}")
raise ValueError(
- f"Template variables not found in inputs: {', '.join(unreplaced_matches)}"
+ f"Template variables not found in inputs: {', '.join(sorted(truly_unreplaced))}"
)
return result
diff --git a/api/oss/src/dbs/postgres/tracing/utils.py b/api/oss/src/dbs/postgres/tracing/utils.py
index 1dead72f74..bf65f645b4 100644
--- a/api/oss/src/dbs/postgres/tracing/utils.py
+++ b/api/oss/src/dbs/postgres/tracing/utils.py
@@ -1935,7 +1935,11 @@ def compute_cqvs(
for k, v in PSC_ITEMS:
if v[0] in pcts and v[1] in pcts:
- pscs[k] = (pcts[v[1]] - pcts[v[0]]) / (pcts[v[1]] + pcts[v[0]])
+ pscs[k] = (
+ (pcts[v[1]] - pcts[v[0]]) / (pcts[v[1]] + pcts[v[0]])
+ if (pcts[v[1]] + pcts[v[0]]) != 0
+ else 0.0
+ )
value["pscs"] = pscs
@@ -1961,7 +1965,9 @@ def compute_pscs(
for k, v in PSC_ITEMS:
if v[0] in pcts and v[1] in pcts:
- pscs[k] = (pcts[v[1]] - pcts[v[0]]) / pcts["p50"]
+ pscs[k] = (
+ (pcts[v[1]] - pcts[v[0]]) / pcts["p50"] if pcts["p50"] != 0 else 0.0
+ )
value["pscs"] = pscs
diff --git a/api/oss/src/services/evaluators_service.py b/api/oss/src/services/evaluators_service.py
index 83913ee43c..b0aedac68b 100644
--- a/api/oss/src/services/evaluators_service.py
+++ b/api/oss/src/services/evaluators_service.py
@@ -533,15 +533,26 @@ def _format_with_template(
elif format == "curly":
import re
+ # Extract variables that exist in the original template before replacement
+ # This allows us to distinguish template variables from {{}} in user input values
+ original_variables = set(re.findall(r"\{\{(.*?)\}\}", content))
+
result = content
for key, value in kwargs.items():
pattern = r"\{\{" + re.escape(key) + r"\}\}"
old_result = result
- result = re.sub(pattern, str(value), result)
- unreplaced_matches = re.findall(r"\{\{(.*?)\}\}", result)
- if unreplaced_matches:
+ # Escape backslashes in the replacement string to prevent regex interpretation
+ escaped_value = str(value).replace("\\", "\\\\")
+ result = re.sub(pattern, escaped_value, result)
+
+ # Only check if ORIGINAL template variables remain unreplaced
+ # Don't error on {{}} that came from user input values
+ unreplaced_matches = set(re.findall(r"\{\{(.*?)\}\}", result))
+ truly_unreplaced = original_variables & unreplaced_matches
+
+ if truly_unreplaced:
raise ValueError(
- f"Template variables not found in inputs: {', '.join(unreplaced_matches)}"
+ f"Template variables not found in inputs: {', '.join(sorted(truly_unreplaced))}"
)
return result
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 241f316ffc..024944f631 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "api"
-version = "0.59.3"
+version = "0.59.4"
description = "Agenta API"
authors = [
{ name = "Mahmoud Mabrouk", email = "mahmoud@agenta.ai" },
diff --git a/sdk/agenta/sdk/types.py b/sdk/agenta/sdk/types.py
index 4440481bf5..448ac23b26 100644
--- a/sdk/agenta/sdk/types.py
+++ b/sdk/agenta/sdk/types.py
@@ -521,13 +521,26 @@ def _format_with_template(self, content: str, kwargs: Dict[str, Any]) -> str:
elif self.template_format == "curly":
import re
+ # Extract variables that exist in the original template before replacement
+ # This allows us to distinguish template variables from {{}} in user input values
+ original_variables = set(re.findall(r"\{\{(.*?)\}\}", content))
+
result = content
for key, value in kwargs.items():
- result = re.sub(r"\{\{" + key + r"\}\}", str(value), result)
- if re.search(r"\{\{.*?\}\}", result):
- unreplaced = re.findall(r"\{\{(.*?)\}\}", result)
+ # Escape backslashes in the replacement string to prevent regex interpretation
+ escaped_value = str(value).replace("\\", "\\\\")
+ result = re.sub(
+ r"\{\{" + re.escape(key) + r"\}\}", escaped_value, result
+ )
+
+ # Only check if ORIGINAL template variables remain unreplaced
+ # Don't error on {{}} that came from user input values
+ unreplaced_matches = set(re.findall(r"\{\{(.*?)\}\}", result))
+ truly_unreplaced = original_variables & unreplaced_matches
+
+ if truly_unreplaced:
raise TemplateFormatError(
- f"Unreplaced variables in curly template: {unreplaced}"
+ f"Unreplaced variables in curly template: {sorted(truly_unreplaced)}"
)
return result
else:
diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml
index fd19935298..8d90fb82ce 100644
--- a/sdk/pyproject.toml
+++ b/sdk/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "agenta"
-version = "0.59.3"
+version = "0.59.4"
description = "The SDK for agenta is an open-source LLMOps platform."
readme = "README.md"
authors = [
diff --git a/web/.husky/install.mjs b/web/.husky/install.mjs
new file mode 100644
index 0000000000..4fe00507c0
--- /dev/null
+++ b/web/.husky/install.mjs
@@ -0,0 +1,6 @@
+// Skip Husky install in production and CI
+if (process.env.NODE_ENV === "production" || process.env.CI === "true") {
+ process.exit(0)
+}
+const husky = (await import("husky")).default
+console.log(husky())
diff --git a/web/ee/package.json b/web/ee/package.json
index 983b7d62b2..24065cd219 100644
--- a/web/ee/package.json
+++ b/web/ee/package.json
@@ -1,6 +1,6 @@
{
"name": "@agenta/ee",
- "version": "0.59.3",
+ "version": "0.59.4",
"private": true,
"engines": {
"node": ">=18"
diff --git a/web/ee/src/components/EvalRunDetails/AutoEvalRun/components/EvalRunFocusDrawer/assets/FocusDrawerContent/index.tsx b/web/ee/src/components/EvalRunDetails/AutoEvalRun/components/EvalRunFocusDrawer/assets/FocusDrawerContent/index.tsx
index e99df5017f..a5a90bc3d2 100644
--- a/web/ee/src/components/EvalRunDetails/AutoEvalRun/components/EvalRunFocusDrawer/assets/FocusDrawerContent/index.tsx
+++ b/web/ee/src/components/EvalRunDetails/AutoEvalRun/components/EvalRunFocusDrawer/assets/FocusDrawerContent/index.tsx
@@ -4,7 +4,7 @@ import SimpleSharedEditor from "@agenta/oss/src/components/EditorViews/SimpleSha
import VirtualizedSharedEditors from "@agenta/oss/src/components/EditorViews/VirtualizedSharedEditors"
import {Collapse, CollapseProps, Tag, Tooltip} from "antd"
import clsx from "clsx"
-import {useAtomValue} from "jotai"
+import {getDefaultStore, useAtomValue} from "jotai"
import {loadable} from "jotai/utils"
import {useRouter} from "next/router"
@@ -17,7 +17,6 @@ import {urlStateAtom} from "@/oss/components/EvalRunDetails/state/urlState"
import {formatMetricValue} from "@/oss/components/HumanEvaluations/assets/MetricDetailsPopover/assets/utils"
import {getStatusLabel} from "@/oss/lib/constants/statusLabels"
import {
- evalAtomStore,
scenarioStepFamily,
evaluationRunStateFamily,
} from "@/oss/lib/hooks/useEvaluationRunData/assets/atoms"
@@ -519,7 +518,7 @@ const FocusDrawerContent = () => {
// Helper: collect evaluator list for a run
const getRunEvaluators = (rId: string) => {
- const rState = evalAtomStore().get(evaluationRunStateFamily(rId))
+ const rState = getDefaultStore().get(evaluationRunStateFamily(rId))
const evaluators = rState?.enrichedRun?.evaluators || []
return Array.isArray(evaluators)
? evaluators
@@ -555,7 +554,7 @@ const FocusDrawerContent = () => {
)
}
- const metricData = evalAtomStore().get(
+ const metricData = getDefaultStore().get(
runScopedMetricDataFamily({
runId: rId,
scenarioId: scId,
@@ -566,7 +565,7 @@ const FocusDrawerContent = () => {
// Run-scoped error fallback
let errorStep: any = null
- const stepLoadableR = evalAtomStore().get(
+ const stepLoadableR = getDefaultStore().get(
loadable(scenarioStepFamily({runId: rId, scenarioId: scId})),
) as any
if (stepLoadableR?.state === "hasData") {
@@ -793,7 +792,8 @@ const FocusDrawerContent = () => {
},
),
children: Object.keys(metrics || {})?.map((metricKey) => {
- const metricData = evalAtomStore().get(
+
+ const metricData = getDefaultStore().get(
runScopedMetricDataFamily({
runId: runId!,
scenarioId: scenarioId!,
@@ -802,10 +802,10 @@ const FocusDrawerContent = () => {
}),
)
- const errorStep =
- !metricData?.distInfo || hasError
- ? getErrorStep(`${evaluator.slug}.${metricKey}`, scenarioId)
- : null
+ const errorStep = getErrorStep(
+ `${evaluator.slug}.${metricKey}`,
+ scenarioId,
+ )
let value
if (
@@ -825,7 +825,6 @@ const FocusDrawerContent = () => {
}
const formatted = formatMetricValue(metricKey, value || "")
-
return (
{
matchedComparisonScenarios,
baseRunId,
invocationStep?.stepkey,
+ getErrorStep,
])
if (stepLoadable.state !== "hasData" || !enricedRun) {
diff --git a/web/ee/src/components/EvalRunDetails/AutoEvalRun/index.tsx b/web/ee/src/components/EvalRunDetails/AutoEvalRun/index.tsx
index 4896a13b70..e7c55ca326 100644
--- a/web/ee/src/components/EvalRunDetails/AutoEvalRun/index.tsx
+++ b/web/ee/src/components/EvalRunDetails/AutoEvalRun/index.tsx
@@ -31,7 +31,7 @@ const AutoEvalRunDetails = ({name, description, id, isLoading}: AutoEvalRunDetai
return (
diff --git a/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ComparisonScenarioTable.tsx b/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ComparisonScenarioTable.tsx
index d60ac8835e..11a935e276 100644
--- a/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ComparisonScenarioTable.tsx
+++ b/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ComparisonScenarioTable.tsx
@@ -3,7 +3,7 @@ import {RefObject, useEffect, useMemo} from "react"
import {DownOutlined} from "@ant-design/icons"
import clsx from "clsx"
import {atom, useAtom, useAtomValue} from "jotai"
-import dynamic from "next/dynamic"
+
import {useResizeObserver} from "usehooks-ts"
import {useRunId} from "@/oss/contexts/RunIdContext"
@@ -15,10 +15,7 @@ import {urlStateAtom} from "../../state/urlState"
import useExpandableComparisonDataSource from "./hooks/useExpandableComparisonDataSource"
import useScrollToScenario from "./hooks/useScrollToScenario"
-const EnhancedTable = dynamic(() => import("@/oss/components/EnhancedUIs/Table"), {
- ssr: false,
- loading: () => ,
-})
+import EnhancedTable from "@/oss/components/EnhancedUIs/Table"
export const expendedRowAtom = atom>({})
@@ -90,7 +87,7 @@ const ComparisonTable = () => {
)
}
- if (loading) {
+ if (loading || !EnhancedTable) {
return
}
diff --git a/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ScenarioTable.tsx b/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ScenarioTable.tsx
index c0219b19c8..dcc9b61896 100644
--- a/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ScenarioTable.tsx
+++ b/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/ScenarioTable.tsx
@@ -9,11 +9,8 @@ import {EvalRunTestCaseTableSkeleton} from "../../AutoEvalRun/components/EvalRun
import useScrollToScenario from "./hooks/useScrollToScenario"
import useTableDataSource from "./hooks/useTableDataSource"
+import EnhancedTable from "@/oss/components/EnhancedUIs/Table"
-const EnhancedTable = dynamic(() => import("@/oss/components/EnhancedUIs/Table"), {
- ssr: false,
- loading: () => ,
-})
const VirtualizedScenarioTableAnnotateDrawer = dynamic(
() => import("./assets/VirtualizedScenarioTableAnnotateDrawer"),
{ssr: false},
@@ -34,33 +31,33 @@ const ScenarioTable = ({runId: propRunId}: {runId?: string}) => {
box: "border-box",
})
+ if (isLoadingSteps || !EnhancedTable) {
+ return
+ }
+
return (
- {isLoadingSteps ? (
-
- ) : (
-
- {!scrollY ? null : (
-
record.key || record.scenarioId}
- className="agenta-scenario-table"
- rowClassName="scenario-row"
- tableLayout="fixed"
- skeletonRowCount={0}
- loading={false}
- ref={tableInstance}
- />
- )}
+
+ {!scrollY ? null : (
+ record.key || record.scenarioId}
+ className="agenta-scenario-table"
+ rowClassName="scenario-row"
+ tableLayout="fixed"
+ skeletonRowCount={0}
+ loading={false}
+ ref={tableInstance}
+ />
+ )}
-
-
- )}
+
+
)
}
diff --git a/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/assets/MetricCell/MetricCell.tsx b/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/assets/MetricCell/MetricCell.tsx
index 39b3631b99..0ffdddfc41 100644
--- a/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/assets/MetricCell/MetricCell.tsx
+++ b/web/ee/src/components/EvalRunDetails/components/VirtualizedScenarioTable/assets/MetricCell/MetricCell.tsx
@@ -81,7 +81,6 @@ const MetricCell = memo(
// Non-numeric arrays rendered as Tag list
let formatted: ReactNode = formatMetricValue(metricKey, value)
-
if (metricType === "boolean" && Array.isArray(value as any)) {
const trueEntry = (distInfo as any).frequency.find((f: any) => f.value === true)
const total = (distInfo as any).count ?? 0
diff --git a/web/ee/src/components/EvalRunDetails/index.tsx b/web/ee/src/components/EvalRunDetails/index.tsx
index fc97714bb4..61a61d1936 100644
--- a/web/ee/src/components/EvalRunDetails/index.tsx
+++ b/web/ee/src/components/EvalRunDetails/index.tsx
@@ -144,7 +144,7 @@ const EvaluationPage = memo(({evalType, runId}: {evalType: "auto" | "human"; run
const breadcrumbs = useAtomValue(breadcrumbAtom, {store: rootStore})
const appendBreadcrumb = useSetAtom(appendBreadcrumbAtom, {store: rootStore})
const setEvalType = useSetAtom(setEvalTypeAtom)
- const appId = useAppId()
+ const router = useRouter()
const {isPreview, name, description, id} = useAtomValue(
selectAtom(
@@ -263,8 +263,9 @@ const EvaluationPage = memo(({evalType, runId}: {evalType: "auto" | "human"; run
return (
{/** TODO: improve the component state specially AutoEvalRunDetails */}
@@ -272,7 +273,7 @@ const EvaluationPage = memo(({evalType, runId}: {evalType: "auto" | "human"; run
) : isPreview && id ? (
@@ -281,14 +282,14 @@ const EvaluationPage = memo(({evalType, runId}: {evalType: "auto" | "human"; run
>
) : (
-
+
)}
)
diff --git a/web/ee/src/components/EvaluationTable/ABTestingEvaluationTable.tsx b/web/ee/src/components/EvaluationTable/ABTestingEvaluationTable.tsx
index ce5467f572..2552406e30 100644
--- a/web/ee/src/components/EvaluationTable/ABTestingEvaluationTable.tsx
+++ b/web/ee/src/components/EvaluationTable/ABTestingEvaluationTable.tsx
@@ -8,7 +8,7 @@ import {getDefaultStore, useAtomValue} from "jotai"
import debounce from "lodash/debounce"
import {useRouter} from "next/router"
-import {useQueryParam} from "@/oss/hooks/useQuery"
+import {useQueryParamState} from "@/oss/state/appState"
import {EvaluationFlow} from "@/oss/lib/enums"
import {exportABTestingEvaluationData} from "@/oss/lib/helpers/evaluate"
import {isBaseResponse, isFuncResponse} from "@/oss/lib/helpers/playgroundResp"
@@ -102,7 +102,22 @@ const ABTestingEvaluationTable: React.FC = ({
const [rows, setRows] = useState([])
const [, setEvaluationStatus] = useState(evaluation.status)
- const [viewMode, setViewMode] = useQueryParam("viewMode", "card")
+ const [viewModeParam, setViewModeParam] = useQueryParamState("viewMode")
+ const viewMode = useMemo(() => {
+ if (Array.isArray(viewModeParam)) {
+ return viewModeParam[0] ?? "card"
+ }
+ if (typeof viewModeParam === "string" && viewModeParam) {
+ return viewModeParam
+ }
+ return "card"
+ }, [viewModeParam])
+ const setViewMode = useCallback(
+ (nextMode: string) => {
+ setViewModeParam(nextMode, {method: "replace", shallow: true})
+ },
+ [setViewModeParam],
+ )
const {data: evaluationResults, mutate} = useEvaluationResults({
evaluationId: evaluation.id,
onSuccess: () => {
diff --git a/web/ee/src/components/EvaluationTable/SingleModelEvaluationTable.tsx b/web/ee/src/components/EvaluationTable/SingleModelEvaluationTable.tsx
index 8273f6aa2e..9fde376831 100644
--- a/web/ee/src/components/EvaluationTable/SingleModelEvaluationTable.tsx
+++ b/web/ee/src/components/EvaluationTable/SingleModelEvaluationTable.tsx
@@ -20,7 +20,7 @@ import debounce from "lodash/debounce"
import {useRouter} from "next/router"
import SecondaryButton from "@/oss/components/SecondaryButton/SecondaryButton"
-import {useQueryParam} from "@/oss/hooks/useQuery"
+import {useQueryParamState} from "@/oss/state/appState"
import {EvaluationFlow} from "@/oss/lib/enums"
import {exportSingleModelEvaluationData} from "@/oss/lib/helpers/evaluate"
import {isBaseResponse, isFuncResponse} from "@/oss/lib/helpers/playgroundResp"
@@ -112,7 +112,22 @@ const SingleModelEvaluationTable: React.FC = ({
const [rows, setRows] = useState([])
const [evaluationStatus, setEvaluationStatus] = useState(evaluation.status)
- const [viewMode, setViewMode] = useQueryParam("viewMode", "card")
+ const [viewModeParam, setViewModeParam] = useQueryParamState("viewMode")
+ const viewMode = useMemo(() => {
+ if (Array.isArray(viewModeParam)) {
+ return viewModeParam[0] ?? "card"
+ }
+ if (typeof viewModeParam === "string" && viewModeParam) {
+ return viewModeParam
+ }
+ return "card"
+ }, [viewModeParam])
+ const setViewMode = useCallback(
+ (nextMode: string) => {
+ setViewModeParam(nextMode, {method: "replace", shallow: true})
+ },
+ [setViewModeParam],
+ )
const [accuracy, setAccuracy] = useState(0)
const [isTestsetModalOpen, setIsTestsetModalOpen] = useState(false)
diff --git a/web/ee/src/components/Evaluations/EvaluationCardView/index.tsx b/web/ee/src/components/Evaluations/EvaluationCardView/index.tsx
index 96aef26f30..2b31bfb74e 100644
--- a/web/ee/src/components/Evaluations/EvaluationCardView/index.tsx
+++ b/web/ee/src/components/Evaluations/EvaluationCardView/index.tsx
@@ -15,7 +15,7 @@ import {useLocalStorage} from "usehooks-ts"
import AlertPopup from "@/oss/components/AlertPopup/AlertPopup"
import ParamsForm from "@/oss/components/ParamsForm"
-import {useQueryParam} from "@/oss/hooks/useQuery"
+import {useQueryParamState} from "@/oss/state/appState"
import {EvaluationType} from "@/oss/lib/enums"
import {testsetRowToChatMessages} from "@/oss/lib/helpers/testset"
import useStatelessVariants from "@/oss/lib/hooks/useStatelessVariants"
@@ -47,10 +47,29 @@ const EvaluationCardView: React.FC = ({
Record
>("evaluationsState", {})
- const [scenarioId, setScenarioId] = useQueryParam(
- "evaluationScenario",
- evaluationsState[evaluation.id]?.lastVisitedScenario || evaluationScenarios[0]?.id || "",
+ const [scenarioParam, setScenarioParam] = useQueryParamState("evaluationScenario")
+ const fallbackScenarioId = useMemo(() => {
+ return (
+ evaluationsState[evaluation.id]?.lastVisitedScenario || evaluationScenarios[0]?.id || ""
+ )
+ }, [evaluation.id, evaluationScenarios, evaluationsState])
+ const scenarioId = useMemo(() => {
+ if (Array.isArray(scenarioParam)) {
+ return scenarioParam[0] || fallbackScenarioId
+ }
+ if (typeof scenarioParam === "string" && scenarioParam) {
+ return scenarioParam
+ }
+ return fallbackScenarioId
+ }, [scenarioParam, fallbackScenarioId])
+ const setScenarioId = useCallback(
+ (nextId: string) => {
+ if (!nextId) return
+ setScenarioParam(nextId, {method: "replace", shallow: true})
+ },
+ [setScenarioParam],
)
+
const [instructionsShown, setInstructionsShown] = useLocalStorage(
"evalInstructionsShown",
false,
diff --git a/web/ee/src/lib/hooks/usePreviewEvaluations/assets/utils.ts b/web/ee/src/lib/hooks/usePreviewEvaluations/assets/utils.ts
index e50327161f..61134926fc 100644
--- a/web/ee/src/lib/hooks/usePreviewEvaluations/assets/utils.ts
+++ b/web/ee/src/lib/hooks/usePreviewEvaluations/assets/utils.ts
@@ -301,7 +301,9 @@ export const enrichEvaluationRun = ({
const projectId = getProjectValues().projectId
const baseVariants = filteredVariants.length ? filteredVariants : []
- const combinedVariants = (baseVariants.length ? baseVariants : fallbackVariants) as typeof fallbackVariants
+ const combinedVariants = (
+ baseVariants.length ? baseVariants : fallbackVariants
+ ) as typeof fallbackVariants
const normalizedVariants = combinedVariants
.map((variant) => {
@@ -374,8 +376,7 @@ export const enrichEvaluationRun = ({
finalAppId &&
(normalizedVariants.find((variant: any) => variant?.appId === finalAppId) ||
originalVariants.find(
- (variant: any) =>
- variant?.appId === finalAppId || variant?.app_id === finalAppId,
+ (variant: any) => variant?.appId === finalAppId || variant?.app_id === finalAppId,
))
const finalAppName = pickString(
diff --git a/web/ee/src/services/observability/api/helper.ts b/web/ee/src/services/observability/api/helper.ts
deleted file mode 100644
index 37f5e2353a..0000000000
--- a/web/ee/src/services/observability/api/helper.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import dayjs from "dayjs"
-
-import {GenerationDashboardData, TracingDashboardData} from "@/oss/lib/types_ee"
-
-export const normalizeDurationSeconds = (d = 0) => d / 1_000
-
-export const formatTick = (ts: number | string, range: string) =>
- dayjs(ts).format(range === "24_hours" ? "h:mm a" : range === "7_days" ? "ddd" : "D MMM")
-
-export function tracingToGeneration(
- tracing: TracingDashboardData,
- range: string,
-): GenerationDashboardData {
- const buckets = tracing.buckets ?? []
-
- let successCount = 0
- let errorCount = 0
- let totalCost = 0
- let totalTokens = 0
- let totalSuccessDuration = 0
-
- const data = buckets.map((b) => {
- const succC = b.total?.count ?? 0
- const errC = b.errors?.count ?? 0
-
- const succCost = b.total?.costs ?? 0
- const errCost = b.errors?.costs ?? 0
-
- const succTok = b.total?.tokens ?? 0
- const errTok = b.errors?.tokens ?? 0
-
- const succDurS = normalizeDurationSeconds(b.total?.duration ?? 0)
-
- successCount += succC
- errorCount += errC
- totalCost += succCost + errCost
- totalTokens += succTok + errTok
- totalSuccessDuration += succDurS
-
- return {
- timestamp: formatTick(b.timestamp, range),
- success_count: succC,
- failure_count: errC,
- cost: succCost + errCost,
- latency: succC ? succDurS / Math.max(succC, 1) : 0, // avg latency per success in the bucket
- total_tokens: succTok + errTok,
- }
- })
-
- const totalCount = successCount + errorCount
-
- return {
- data,
- total_count: totalCount,
- failure_rate: totalCount ? errorCount / totalCount : 0,
- total_cost: totalCost,
- avg_cost: totalCount ? totalCost / totalCount : 0,
- avg_latency: successCount ? totalSuccessDuration / successCount : 0,
- total_tokens: totalTokens,
- avg_tokens: totalCount ? totalTokens / totalCount : 0,
- }
-}
diff --git a/web/ee/src/services/observability/api/index.ts b/web/ee/src/services/observability/api/index.ts
deleted file mode 100644
index 2dabfe1c40..0000000000
--- a/web/ee/src/services/observability/api/index.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import axios from "@/oss/lib/api/assets/axiosConfig"
-import {delay, pickRandom} from "@/oss/lib/helpers/utils"
-import {GenericObject, WithPagination} from "@/oss/lib/Types"
-import {Generation, GenerationDetails, Trace, TracingDashboardData} from "@/oss/lib/types_ee"
-import {getProjectValues} from "@/oss/state/project"
-
-import {tracingToGeneration} from "./helper"
-import {ObservabilityMock} from "./mock"
-
-//Prefix convention:
-// - fetch: GET single entity from server
-// - fetchAll: GET all entities from server
-// - create: POST data to server
-// - update: PUT data to server
-// - delete: DELETE data from server
-
-const mock = false
-
-interface TableParams {
- pagination?: {
- page: number
- pageSize: number
- }
- sorters?: GenericObject
- filters?: GenericObject
-}
-
-function tableParamsToApiParams(options?: Partial) {
- const {page = 1, pageSize = 20} = options?.pagination || {}
- const res: GenericObject = {page, pageSize}
- if (options?.sorters) {
- Object.entries(options.sorters).forEach(
- ([key, val]) => (res[key] = val === "ascend" ? "asc" : "desc"),
- )
- }
- if (options?.filters) {
- Object.entries(options.filters).forEach(([key, val]) => (res[key] = val))
- }
- return res
-}
-
-const generations = pickRandom(ObservabilityMock.generations, 100).map((item, ix) => ({
- ...item,
- id: ix + 1 + "",
-}))
-
-export const fetchAllGenerations = async (appId: string, options?: Partial) => {
- const {projectId} = getProjectValues()
-
- const params = tableParamsToApiParams(options)
- if (mock) {
- const {page, pageSize} = params
- await delay(800)
- return {
- data: generations.slice((page - 1) * pageSize, page * pageSize),
- total: generations.length,
- page,
- pageSize,
- } as WithPagination
- }
-
- const response = await axios.get(`/observability/spans?project_id=${projectId}`, {
- params: {app_id: appId, type: "generation", ...params},
- })
- return response.data as WithPagination
-}
-
-export const fetchGeneration = async (generationId: string) => {
- const {projectId} = getProjectValues()
-
- if (mock) {
- await delay(800)
- const generation = generations.find((item) => item.id === generationId)
- if (!generation) throw new Error("not found!")
-
- return {
- ...generation,
- ...ObservabilityMock.generationDetail,
- } as GenerationDetails
- }
-
- const response = await axios.get(
- `/observability/spans/${generationId}?project_id=${projectId}`,
- {
- params: {type: "generation"},
- },
- )
- return response.data as GenerationDetails
-}
-
-export const fetchGenerationsDashboardData = async (
- appId: string | null | undefined,
- _options: {
- range: string
- environment?: string
- variant?: string
- projectId?: string
- signal?: AbortSignal
- },
-) => {
- const {projectId: propsProjectId, signal, ...options} = _options
- const {projectId: _projectId} = getProjectValues()
- const projectId = propsProjectId || _projectId
-
- const {range} = options
-
- if (signal?.aborted) {
- throw new DOMException("Aborted", "AbortError")
- }
-
- const responseTracing = await axios.post(
- `/preview/tracing/spans/analytics?project_id=${projectId}`,
- {
- focus: "trace",
- interval: 720,
- filter: {
- conditions: [
- {
- field: "references",
- operator: "in",
- value: [
- {
- id: appId,
- },
- ],
- },
- ],
- },
- },
- )
-
- const valTracing = responseTracing.data as TracingDashboardData
- return tracingToGeneration(valTracing, range)
-}
-
-export const deleteGeneration = async (
- generationIds: string[],
- type = "generation",
- ignoreAxiosError = true,
-) => {
- const {projectId} = getProjectValues()
-
- await axios.delete(`/observability/spans?project_id=${projectId}`, {
- data: generationIds,
- _ignoreError: ignoreAxiosError,
- } as any)
- return true
-}
-
-export const fetchAllTraces = async (appId: string, options?: Partial) => {
- const {projectId} = getProjectValues()
-
- const params = tableParamsToApiParams(options)
- if (mock) {
- const {page, pageSize} = params
- await delay(800)
- return {
- data: generations.slice((page - 1) * pageSize, page * pageSize),
- total: generations.length,
- page,
- pageSize,
- } as WithPagination
- }
- const response = await axios.get(`/observability/traces?project_id=${projectId}`, {
- params: {app_id: appId, type: "generation", ...params},
- })
- return response.data as WithPagination
-}
diff --git a/web/ee/src/services/observability/api/mock.ts b/web/ee/src/services/observability/api/mock.ts
deleted file mode 100644
index a13e172d46..0000000000
--- a/web/ee/src/services/observability/api/mock.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import dayjs from "dayjs"
-
-import {randNum} from "@/oss/lib/helpers/utils"
-import {
- Generation,
- GenerationKind,
- GenerationDashboardData,
- GenerationStatus,
-} from "@/oss/lib/types_ee"
-
-const generations: Generation[] = [
- {
- id: "1",
- created_at: "2021-10-01T00:00:00Z",
- variant: {
- variant_id: "1",
- variant_name: "default",
- revision: 1,
- },
- environment: "production",
- status: GenerationStatus.OK,
- spankind: GenerationKind.LLM,
- metadata: {
- cost: 0.0001,
- latency: 0.32,
- usage: {
- total_tokens: 72,
- prompt_tokens: 25,
- completion_tokens: 47,
- },
- },
- user_id: "u-8k3j4",
- content: {
- inputs: [
- {input_name: "country", input_value: "Pakistan"},
- {input_name: "criteria", input_value: "Most population"},
- ],
- outputs: ["The most populous city in Pakistan is Karachi"],
- internals: [],
- },
- },
- {
- id: "2",
- created_at: "2023-10-01T00:00:00Z",
- variant: {
- variant_id: "2",
- variant_name: "test",
- revision: 1,
- },
- environment: "staging",
- status: GenerationStatus.ERROR,
- spankind: GenerationKind.LLM,
- metadata: {
- cost: 0.0004,
- latency: 0.845,
- usage: {
- total_tokens: 143,
- prompt_tokens: 25,
- completion_tokens: 118,
- },
- },
- user_id: "u-8k3j4",
- content: {
- inputs: [],
- outputs: [],
- internals: [],
- },
- },
- {
- id: "3",
- created_at: "2024-10-01T00:00:00Z",
- variant: {
- variant_id: "1",
- variant_name: "default",
- revision: 2,
- },
- environment: "development",
- status: GenerationStatus.OK,
- spankind: GenerationKind.LLM,
- metadata: {
- cost: 0.0013,
- latency: 0.205,
- usage: {
- total_tokens: 61,
- prompt_tokens: 25,
- completion_tokens: 36,
- },
- },
- user_id: "u-7tij2",
- content: {
- inputs: [],
- outputs: [],
- internals: [],
- },
- },
-]
-
-const generationDetail = {
- content: {
- inputs: [
- {input_name: "country", input_value: "Pakistan"},
- {input_name: "criteria", input_value: "Most population"},
- ],
- outputs: ["The most populous city in Pakistan is Karachi"],
- internals: [],
- },
- config: {
- system: "You are an expert in geography.",
- user: "What is the city of {country} with the criteria {criteria}?",
- variables: [
- {name: "country", type: "string"},
- {name: "criteria", type: "string"},
- ],
- temperature: 0.7,
- model: "gpt-3.5-turbo",
- max_tokens: 100,
- top_p: 0.9,
- frequency_penalty: 0.5,
- presence_penalty: 0,
- },
-}
-
-const dashboardData = (count = 300): GenerationDashboardData["data"] => {
- return Array(count)
- .fill(true)
- .map(() => {
- const totalTokens = randNum(0, 600)
- const promptTokens = randNum(0, 150)
- return {
- timestamp: randNum(dayjs().subtract(30, "days").valueOf(), dayjs().valueOf()), // b/w last 30 days
- success_count: randNum(0, 20),
- failure_count: randNum(0, 5),
- latency: Math.random() * 1.5,
- cost: Math.random() * 0.01,
- total_tokens: totalTokens,
- prompt_tokens: promptTokens,
- completion_tokens: totalTokens - promptTokens,
- enviornment: ["production", "staging", "development"][randNum(0, 2)],
- variant: "default",
- }
- })
-}
-
-export const ObservabilityMock = {
- generations,
- generationDetail,
- dashboardData,
-}
diff --git a/web/ee/src/state/observability/dashboard.ts b/web/ee/src/state/observability/dashboard.ts
index a21cbd2950..e8c0215a71 100644
--- a/web/ee/src/state/observability/dashboard.ts
+++ b/web/ee/src/state/observability/dashboard.ts
@@ -3,9 +3,9 @@ import {eagerAtom} from "jotai-eager"
import {atomWithQuery} from "jotai-tanstack-query"
import {GenerationDashboardData} from "@/oss/lib/types_ee"
-import {fetchGenerationsDashboardData} from "@/oss/services/observability/api"
import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher"
import {projectIdAtom} from "@/oss/state/project"
+import {fetchGenerationsDashboardData} from "@/oss/services/tracing/api"
const DEFAULT_RANGE = "30_days"
diff --git a/web/oss/package.json b/web/oss/package.json
index 2f5082d263..aba1172dbc 100644
--- a/web/oss/package.json
+++ b/web/oss/package.json
@@ -1,6 +1,6 @@
{
"name": "@agenta/oss",
- "version": "0.59.3",
+ "version": "0.59.4",
"private": true,
"engines": {
"node": ">=18"
diff --git a/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/assets/CommitVariantChangesModalContent/index.tsx b/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/assets/CommitVariantChangesModalContent/index.tsx
index 1f9d87af9e..7114bd1f7d 100644
--- a/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/assets/CommitVariantChangesModalContent/index.tsx
+++ b/web/oss/src/components/Playground/Components/Modals/CommitVariantChangesModal/assets/CommitVariantChangesModalContent/index.tsx
@@ -157,6 +157,7 @@ const CommitVariantChangesModalContent = ({
placeholder="A unique variant name"
className="w-full max-w-xs"
value={selectedCommitType?.name}
+ disabled={selectedCommitType?.type !== "variant"}
onChange={(e) =>
setSelectedCommitType((prev) => {
const prevType = prev?.type
diff --git a/web/oss/src/components/Playground/Components/PlaygroundTool/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundTool/index.tsx
index c190f64f9e..96b0a254d1 100644
--- a/web/oss/src/components/Playground/Components/PlaygroundTool/index.tsx
+++ b/web/oss/src/components/Playground/Components/PlaygroundTool/index.tsx
@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useMemo, useRef, useState} from "react"
+import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"
import {Input, Tooltip} from "antd"
import clsx from "clsx"
@@ -17,141 +17,358 @@ import SharedEditor from "../SharedEditor"
import {TOOL_SCHEMA} from "./assets"
-const PlaygroundTool = ({value, disabled, variantId, baseProperty, ...editorProps}) => {
- const editorIdRef = useRef(uuidv4())
- const isReadOnly = Boolean(disabled)
- const [minimized, setMinimized] = useState(false)
- const [toolString, setToolString] = useState(() => {
- try {
- if (!value) {
- return ""
- }
- return typeof value === "string" ? value : JSON5.stringify(value, null, 2)
- } catch (e) {
- return ""
- }
- })
- const [functionName, setFunctionName] = useState(() => {
- try {
- return typeof value === "string"
- ? (JSON5.parse(value)?.function?.name ?? "")
- : (value?.function?.name ?? "")
- } catch (err) {
- return ""
- }
- })
- const [functionDescription, setFunctionDescription] = useState(() => {
- try {
- return typeof value === "string"
- ? (JSON5.parse(value)?.function?.description ?? "")
- : (value?.function?.description ?? "")
- } catch (err) {
- return ""
+export type ToolFunction = {
+ name?: string
+ description?: string
+ [k: string]: any
+}
+
+export type ToolObj = {
+ function?: ToolFunction
+ [k: string]: any
+} | null
+
+export interface PlaygroundToolProps {
+ value: unknown
+ disabled?: boolean
+ variantId: string
+ baseProperty?: {__id?: string} & Record
+ editorProps?: {
+ handleChange?: (obj: ToolObj) => void
+ }
+}
+
+function safeStringify(obj: any): string {
+ try {
+ return JSON.stringify(obj, null, 2)
+ } catch {
+ return ""
+ }
+}
+
+// stable stringify - sorts keys so deep equals is reliable
+function stableStringify(input: any): string {
+ const seen = new WeakSet()
+ function sortKeys(value: any): any {
+ if (value && typeof value === "object") {
+ if (seen.has(value)) return null // guard against cycles
+ seen.add(value)
+ if (Array.isArray(value)) return value.map(sortKeys)
+ const out: Record = {}
+ Object.keys(value)
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
+ .forEach((k) => {
+ out[k] = sortKeys(value[k])
+ })
+ return out
}
- })
-
- const parsed = useMemo(() => {
- if (!toolString) return null
- try {
- return JSON5.parse(toolString)
- } catch (e) {
- return null
+ return value
+ }
+ try {
+ return JSON.stringify(sortKeys(input))
+ } catch {
+ return ""
+ }
+}
+
+function deepEqual(a: any, b: any): boolean {
+ return stableStringify(a) === stableStringify(b)
+}
+
+function toToolObj(value: unknown): ToolObj {
+ try {
+ if (typeof value === "string") return value ? (JSON5.parse(value) as ToolObj) : {}
+ if (value && typeof value === "object") return value as ToolObj
+ return {}
+ } catch {
+ // Keep last known good
+ return {}
+ }
+}
+
+/**
+ * Two-way state that keeps editor JSON, parsed object, and header inputs in sync.
+ * - Debounces write backs into the editor to avoid stealing focus while typing in headers
+ * - Emits onChange only when the canonical object actually changes (deep compare)
+ * - Reacts to external prop changes to `initialValue`
+ */
+function useTwoWayToolState(
+ initialValue: unknown,
+ isReadOnly: boolean,
+ onChange?: (obj: ToolObj) => void,
+) {
+ const [toolObj, setToolObj] = useState(() => toToolObj(initialValue))
+ const [editorText, setEditorText] = useState(() => safeStringify(toolObj ?? {}))
+ const [editorValid, setEditorValid] = useState(true)
+
+ // Header drafts and focus guards
+ const [isEditingName, setIsEditingName] = useState(false)
+ const [isEditingDesc, setIsEditingDesc] = useState(false)
+ const [nameDraft, setNameDraft] = useState(() => toolObj?.function?.name ?? "")
+ const [descDraft, setDescDraft] = useState(() => toolObj?.function?.description ?? "")
+
+ // Last sent payload to avoid duplicate onChange calls
+ const lastSentSerializedRef = useRef(stableStringify(toolObj))
+
+ // Keep drafts synced when not being edited
+ useEffect(() => {
+ if (!isEditingName) setNameDraft(toolObj?.function?.name ?? "")
+ if (!isEditingDesc) setDescDraft(toolObj?.function?.description ?? "")
+ }, [toolObj, isEditingName, isEditingDesc])
+
+ // Emit to parent when canonical state changes
+ useEffect(() => {
+ if (isReadOnly || !onChange) return
+ const current = stableStringify(toolObj)
+ if (current !== lastSentSerializedRef.current) {
+ lastSentSerializedRef.current = current
+ onChange(toolObj)
}
- }, [toolString])
+ }, [toolObj, onChange, isReadOnly])
- const syncToolFunctionField = useCallback(
- (updater: (toolFunction: any) => any) => {
- if (isReadOnly) return
+ // Debounced serializer for pushing toolObj changes back into editorText
+ const serTimerRef = useRef | null>(null)
+ const scheduleSerialize = useCallback((nextObj: ToolObj) => {
+ // Immediate update to avoid dropped first-change issues
+ setEditorText(safeStringify(nextObj ?? {}))
+ setEditorValid(true)
+ }, [])
- setToolString((currentString) => {
- if (!currentString) return currentString
-
- try {
- const parsedTool = JSON5.parse(currentString)
- const currentFunction = parsedTool.function ?? {}
- const nextFunction = updater(currentFunction)
- const isSameReference =
- (parsedTool.function && nextFunction === parsedTool.function) ||
- nextFunction === currentFunction
- const nextFunctionIsObject =
- typeof nextFunction === "object" && nextFunction !== null
- const isEmptyNoop =
- !parsedTool.function &&
- nextFunction === currentFunction &&
- (!nextFunctionIsObject || Object.keys(nextFunction).length === 0)
-
- if (isSameReference || isEmptyNoop) {
- return currentString
- }
+ useEffect(
+ () => () => {
+ if (serTimerRef.current) clearTimeout(serTimerRef.current)
+ },
+ [],
+ )
- const updatedTool = {
- ...parsedTool,
- function: nextFunction,
- }
+ // React to external initialValue changes
+ const lastPropValueRef = useRef(stableStringify(toToolObj(initialValue)))
+ useEffect(() => {
+ const nextParsed = toToolObj(initialValue)
+ const nextSerialized = stableStringify(nextParsed)
+ if (nextSerialized !== lastPropValueRef.current) {
+ lastPropValueRef.current = nextSerialized
+ setToolObj(nextParsed)
+ setEditorText(safeStringify(nextParsed ?? {}))
+ setEditorValid(true)
+ if (!isEditingName) setNameDraft(nextParsed?.function?.name ?? "")
+ if (!isEditingDesc) setDescDraft(nextParsed?.function?.description ?? "")
+ }
+ }, [initialValue])
- const nextString = JSON.stringify(updatedTool, null, 2)
- return nextString === currentString ? currentString : nextString
- } catch (err) {
- console.error(err)
- return currentString
+ const onEditorChange = useCallback(
+ (text: string) => {
+ if (isReadOnly) return
+ setEditorText(text)
+ try {
+ const parsed = text ? (JSON5.parse(text) as ToolObj) : {}
+ setEditorValid(true)
+ setToolObj((prev) => (deepEqual(prev, parsed) ? prev : parsed))
+ if (!isEditingName) setNameDraft(parsed?.function?.name ?? "")
+ if (!isEditingDesc) setDescDraft(parsed?.function?.description ?? "")
+ } catch {
+ setEditorValid(false)
+ }
+ },
+ [isReadOnly, isEditingName, isEditingDesc],
+ )
+
+ const setFunctionName = useCallback(
+ (nextName: string) => {
+ if (isReadOnly) return
+ setNameDraft(nextName)
+ setToolObj((prev) => {
+ const base = prev ?? {}
+ const nextObj = {
+ ...base,
+ function: {
+ ...(base.function ?? {}),
+ name: nextName,
+ },
}
+ scheduleSerialize(nextObj)
+ return nextObj
})
},
- [isReadOnly],
+ [isReadOnly, scheduleSerialize],
)
- useEffect(() => {
- if (isReadOnly) return
- try {
- const parsedTool = JSON5.parse(toolString)
- editorProps?.handleChange?.(parsedTool)
- setFunctionName((currentName) => {
- const nextName = parsedTool?.function?.name ?? ""
- if (currentName !== nextName) {
- return nextName
+ const setFunctionDescription = useCallback(
+ (nextDesc: string) => {
+ if (isReadOnly) return
+ setDescDraft(nextDesc)
+ setToolObj((prev) => {
+ const base = prev ?? {}
+ const nextObj = {
+ ...base,
+ function: {
+ ...(base.function ?? {}),
+ description: nextDesc,
+ },
}
- return currentName
+ scheduleSerialize(nextObj)
+ return nextObj
})
- setFunctionDescription((currentDescription) => {
- const nextDescription = parsedTool?.function?.description ?? ""
- if (currentDescription !== nextDescription) {
- return nextDescription
+ },
+ [isReadOnly, scheduleSerialize],
+ )
+
+ return {
+ toolObj,
+ editorText,
+ editorValid,
+ nameDraft,
+ descDraft,
+ setFunctionName,
+ setFunctionDescription,
+ onEditorChange,
+ setIsEditingName,
+ setIsEditingDesc,
+ }
+}
+
+/**
+ * Header component - isolated to avoid re-creating callbacks unnecessarily
+ */
+function ToolHeader(props: {
+ idForActions: string
+ name: string
+ desc: string
+ editorValid: boolean
+ isReadOnly: boolean
+ minimized: boolean
+ onNameFocus: () => void
+ onNameBlur: () => void
+ onDescFocus: () => void
+ onDescBlur: () => void
+ onNameChange: (v: string) => void
+ onDescChange: (v: string) => void
+ onToggleMinimize: () => void
+ onDelete?: () => void
+}) {
+ const {
+ name,
+ desc,
+ editorValid,
+ isReadOnly,
+ minimized,
+ onNameFocus,
+ onNameBlur,
+ onDescFocus,
+ onDescBlur,
+ onNameChange,
+ onDescChange,
+ onToggleMinimize,
+ onDelete,
+ } = props
+
+ return (
+
+ )
+}
+
+const PlaygroundTool: React.FC = ({
+ value,
+ disabled,
+ variantId,
+ baseProperty,
+ editorProps,
+}) => {
+ const editorIdRef = useRef(uuidv4())
+ const isReadOnly = Boolean(disabled)
+ const [minimized, setMinimized] = useState(false)
+
+ const {
+ toolObj,
+ editorText,
+ editorValid,
+ nameDraft,
+ descDraft,
+ setFunctionName,
+ setFunctionDescription,
+ onEditorChange,
+ setIsEditingName,
+ setIsEditingDesc,
+ } = useTwoWayToolState(value, isReadOnly, editorProps?.handleChange)
+
+ useAtomValue(variantByRevisionIdAtomFamily(variantId))
const appUriInfo = useAtomValue(appUriInfoAtom)
const setPrompts = useSetAtom(
- useMemo(() => promptsAtomFamily(variantId), [variant, variantId, appUriInfo?.routePath]),
+ useMemo(() => promptsAtomFamily(variantId), [variantId, appUriInfo?.routePath]),
)
+
const deleteMessage = useCallback(() => {
if (isReadOnly) return
- if (!baseProperty?.__id) {
- console.warn("Cannot delete tool: tool property ID not found")
+ const id = baseProperty?.__id
+ if (!id) {
+ console.warn("Cannot delete tool - missing tool property ID")
return
}
setPrompts((prevPrompts: any[] = []) => {
return prevPrompts.map((prompt: any) => {
const toolsArr = prompt?.llmConfig?.tools?.value
if (Array.isArray(toolsArr)) {
- const updatedTools = toolsArr.filter(
- (tool: any) => tool.__id !== baseProperty.__id,
- )
+ const updatedTools = toolsArr.filter((tool: any) => tool.__id !== id)
if (updatedTools.length !== toolsArr.length) {
return {
...prompt,
llmConfig: {
...prompt.llmConfig,
tools: {
- ...prompt.llmConfig.tools,
+ ...prompt.llmConfig?.tools,
value: updatedTools,
},
},
@@ -161,14 +378,13 @@ const PlaygroundTool = ({value, disabled, variantId, baseProperty, ...editorProp
return prompt
})
})
- }, [isReadOnly, variantId, baseProperty?.__id, setPrompts])
+ }, [isReadOnly, baseProperty?.__id, setPrompts])
+
return (
_div]:!w-auto [&_>_div]:!grow !my-0",
- {
- "[_.agenta-shared-editor]:w-full": isReadOnly,
- },
+ {"[_.agenta-shared-editor]:w-full": isReadOnly},
)}
>
{
if (isReadOnly) return
- setToolString(e)
+ onEditorChange(e)
}}
syncWithInitialValueChanges
editorType="border"
className={clsx([
"mt-2",
- {
- "[&_.agenta-editor-wrapper]:h-[calc(8px+calc(3*19.88px))] [&_.agenta-editor-wrapper]:overflow-y-auto [&_.agenta-editor-wrapper]:!mb-0":
- minimized,
- "[&_.agenta-editor-wrapper]:h-fit": !minimized,
- },
+ minimized
+ ? "[&_.agenta-editor-wrapper]:h-[calc(8px+calc(3*19.88px))] [&_.agenta-editor-wrapper]:overflow-y-auto [&_.agenta-editor-wrapper]:!mb-0"
+ : "[&_.agenta-editor-wrapper]:h-fit",
])}
state={isReadOnly ? "readOnly" : "filled"}
header={
-
-
-
-
{
- setMinimized((current) => !current)
- },
- }
- }
- hideMarkdownToggle={true}
- />
-
+ setIsEditingName(true)}
+ onNameBlur={() => setIsEditingName(false)}
+ onDescFocus={() => setIsEditingDesc(true)}
+ onDescBlur={() => setIsEditingDesc(false)}
+ onNameChange={setFunctionName}
+ onDescChange={setFunctionDescription}
+ onToggleMinimize={() => setMinimized((v) => !v)}
+ onDelete={deleteMessage}
+ />
}
/>
diff --git a/web/oss/src/components/pages/observability/assets/ObservabilityHeader/index.tsx b/web/oss/src/components/pages/observability/assets/ObservabilityHeader/index.tsx
index f1be7b7087..6c71a97bcb 100644
--- a/web/oss/src/components/pages/observability/assets/ObservabilityHeader/index.tsx
+++ b/web/oss/src/components/pages/observability/assets/ObservabilityHeader/index.tsx
@@ -267,7 +267,7 @@ const ObservabilityHeader = ({columns}: ObservabilityHeaderProps) => {
/>
)}
{
- const query = parse(window.location.search.replace("?", ""))
-
- //do not update query if the value is the same
- let changed = false
- for (const key in queryObj) {
- if (query[key]?.toString() !== queryObj[key]?.toString()) {
- changed = true
- break
- }
- }
- if (!changed) return
-
- const newQuery = {
- ...query,
- ...queryObj,
- }
- //delete keys with undefined values
- Object.keys(newQuery).forEach((key) => {
- if (newQuery[key] === undefined || newQuery[key] === "") {
- delete newQuery[key]
- }
- })
-
- router[method](
- {
- pathname: window.location.pathname,
- query: newQuery,
- },
- undefined,
- {scroll: false},
- )
+const normalizeValue = (
+ value: string | string[] | number | boolean | null | undefined,
+): string | string[] | undefined => {
+ if (value === null || value === undefined) return undefined
+ if (Array.isArray(value)) {
+ const normalizedArray = value
+ .map((item) => {
+ if (item === null || item === undefined) return undefined
+ const str = String(item)
+ return str === "" ? undefined : str
+ })
+ .filter((item): item is string => item !== undefined)
+
+ return normalizedArray.length > 0 ? normalizedArray : undefined
}
+
+ const normalized = String(value)
+ return normalized === "" ? undefined : normalized
+}
+
+const valuesAreEqual = (
+ current: string | string[] | undefined,
+ next: string | string[] | undefined,
+) => {
+ if (current === next) return true
+ if (Array.isArray(current) && Array.isArray(next)) {
+ if (current.length !== next.length) return false
+ return current.every((value, index) => value === next[index])
+ }
+ return !Array.isArray(current) && !Array.isArray(next) && current === next
}
export function useQuery(
method: Method = "push",
): [ParsedUrlQuery, (query: ParsedUrlQuery) => void] {
- const router = useRouter()
- const {query} = router
+ const query = useAppQuery()
+ const navigation = useAppNavigation()
+ const queryRef = useRef(query)
+
+ useEffect(() => {
+ queryRef.current = query
+ }, [query])
- return [query, getUpdateQuery(router, method)]
+ const updateQuery = useCallback(
+ (queryObj: ParsedUrlQuery) => {
+ const nextQuery: Record = {}
+ let hasChanged = false
+ const currentQuery = queryRef.current
+
+ Object.keys(queryObj).forEach((key) => {
+ const requestedValue = normalizeValue(queryObj[key] as any)
+ const currentValue = normalizeValue(currentQuery[key] as any)
+
+ if (!valuesAreEqual(currentValue, requestedValue)) {
+ hasChanged = true
+ }
+
+ nextQuery[key] = requestedValue
+ })
+
+ if (!hasChanged) return
+
+ navigation.patchQuery(nextQuery, {method, shallow: true})
+ },
+ [method, navigation],
+ )
+
+ return [query, updateQuery]
}
export function useQueryParam(
paramName: string,
defaultValue?: string,
method?: Method,
-): [string, (val: string) => void] {
+): [string | undefined, (val: string | undefined) => void] {
const [query, updateQuery] = useQuery(method)
- const value = (query as Record)[paramName] || defaultValue
+ const rawValue = query[paramName]
+ const value = Array.isArray(rawValue) ? rawValue[0] : (rawValue as string | undefined)
- const setValue = (val: string) => {
- updateQuery({[paramName]: val})
- }
+ const setValue = useCallback(
+ (val: string | undefined) => {
+ updateQuery({[paramName]: val})
+ },
+ [paramName, updateQuery],
+ )
- return [value, setValue]
+ return [value ?? defaultValue, setValue]
}
diff --git a/web/oss/src/lib/helpers/tracing.ts b/web/oss/src/lib/helpers/tracing.ts
index 8b0340fa50..3bd829041e 100644
--- a/web/oss/src/lib/helpers/tracing.ts
+++ b/web/oss/src/lib/helpers/tracing.ts
@@ -12,7 +12,7 @@
* @param spans - Array of spans to sort
* @returns New sorted array (does not mutate input)
*/
-export const sortSpansByStartTime = (
+export const sortSpansByStartTime = (
spans: T[],
): T[] => {
return [...spans].sort((a, b) => {
diff --git a/web/oss/src/services/observability/core/index.ts b/web/oss/src/services/observability/core/index.ts
deleted file mode 100644
index ecd67e6ddd..0000000000
--- a/web/oss/src/services/observability/core/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import {fetchJson, getBaseUrl, ensureProjectId, ensureAppId} from "@/oss/lib/api/assets/fetchClient"
-
-//Prefix convention:
-// - fetch: GET single entity from server
-// - fetchAll: GET all entities from server
-// - create: POST data to server
-// - update: PUT data to server
-// - delete: DELETE data from server
-
-export const fetchAllTraces = async (params = {}, appId: string) => {
- const base = getBaseUrl()
- const projectId = ensureProjectId()
- const applicationId = ensureAppId(appId)
-
- const url = new URL(`${base}/observability/v1/traces`)
- if (projectId) url.searchParams.set("project_id", projectId)
- if (applicationId) url.searchParams.set("application_id", applicationId)
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== null) {
- url.searchParams.set(key, String(value))
- }
- })
-
- return fetchJson(url)
-}
-
-export const deleteTrace = async (nodeId: string) => {
- const base = getBaseUrl()
- const projectId = ensureProjectId()
-
- const url = new URL(`${base}/observability/v1/traces`)
- if (projectId) url.searchParams.set("project_id", projectId)
- url.searchParams.set("node_id", nodeId)
-
- return fetchJson(url, {method: "DELETE"})
-}
diff --git a/web/oss/src/services/observability/hooks/useTraces.ts b/web/oss/src/services/observability/hooks/useTraces.ts
deleted file mode 100644
index 4b180580ab..0000000000
--- a/web/oss/src/services/observability/hooks/useTraces.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import {useEffect, useMemo} from "react"
-
-import {useAtomValue} from "jotai"
-import useSWR, {preload} from "swr"
-
-import {buildNodeTree, observabilityTransformer} from "@/oss/lib/helpers/observability_helpers"
-import {projectIdAtom} from "@/oss/state/project"
-
-import {fetchAllTraces} from "../core"
-import {_AgentaRootsResponse, AgentaNodeDTO, AgentaRootsDTO, AgentaTreeDTO} from "../types"
-
-export const useTraces = (
- {
- pagination,
- sort,
- filters,
- traceTabs,
- autoPrefetch,
- waitUntil,
- }: {
- pagination: {size: number; page: number}
- sort: {type: string; sorted: string; customRange?: {startTime: string; endTime: string}}
- filters: any[]
- traceTabs: string
- autoPrefetch?: boolean
- waitUntil?: boolean
- },
- appId: string,
-) => {
- const queryParams = useMemo(() => {
- const params: Record = {
- size: pagination.size,
- page: pagination.page,
- focus: traceTabs === "chat" ? "node" : traceTabs,
- }
-
- if (filters.length > 0) {
- const sanitizedFilters = filters.map(({isPermanent, ...rest}) => rest)
-
- params.filtering = JSON.stringify({conditions: sanitizedFilters})
- }
-
- if (sort) {
- if (sort.type === "standard") {
- params.oldest = sort.sorted
- } else if (
- sort.type === "custom" &&
- (sort.customRange?.startTime || sort.customRange?.endTime)
- ) {
- const {startTime, endTime} = sort.customRange
-
- if (startTime) params.oldest = startTime
- if (endTime) params.newest = endTime
- }
- }
-
- return params
- }, [traceTabs, pagination.size, pagination.page, filters, sort])
-
- const fetcher = async () => {
- const data = await fetchAllTraces(queryParams, appId)
-
- const transformedTraces: _AgentaRootsResponse[] = []
-
- if (data?.roots) {
- transformedTraces.push(
- ...data.roots.flatMap((item: AgentaRootsDTO) =>
- observabilityTransformer(item.trees[0]),
- ),
- )
- }
-
- if (data?.trees) {
- transformedTraces.push(
- ...data.trees.flatMap((item: AgentaTreeDTO) => observabilityTransformer(item)),
- )
- }
-
- if (data?.nodes) {
- transformedTraces.push(
- ...data.nodes
- .flatMap((node: AgentaNodeDTO) => buildNodeTree(node))
- .flatMap((item: AgentaTreeDTO) => observabilityTransformer(item)),
- )
- }
-
- return {
- traces: transformedTraces,
- traceCount: data?.count,
- }
- }
-
- const prefetchPage = async (pageNumber: number) => {
- const nextParams = {
- ...queryParams,
- page: pageNumber,
- }
- const key = ["traces", appId, JSON.stringify(nextParams)]
- await preload(key, async () => {
- const data = await fetchAllTraces(nextParams, appId)
-
- const transformedTraces: _AgentaRootsResponse[] = []
-
- if (data?.roots) {
- transformedTraces.push(
- ...data.roots.flatMap((item: AgentaRootsDTO) =>
- observabilityTransformer(item.trees[0]),
- ),
- )
- }
-
- if (data?.trees) {
- transformedTraces.push(
- ...data.trees.flatMap((item: AgentaTreeDTO) => observabilityTransformer(item)),
- )
- }
-
- if (data?.nodes) {
- transformedTraces.push(
- ...data.nodes
- .flatMap((node: AgentaNodeDTO) => buildNodeTree(node))
- .flatMap((item: AgentaTreeDTO) => observabilityTransformer(item)),
- )
- }
-
- return {
- traces: transformedTraces,
- traceCount: data?.count,
- }
- })
- }
-
- useEffect(() => {
- if (autoPrefetch && !waitUntil) {
- prefetchPage(pagination.page + 1)
- }
- }, [autoPrefetch, pagination.page, JSON.stringify(queryParams), appId, waitUntil])
-
- const projectId = useAtomValue(projectIdAtom)
- const swrKey = !projectId || waitUntil ? null : ["traces", appId, JSON.stringify(queryParams)]
- const swr = useSWR(swrKey, fetcher, {
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- })
-
- return swr
-}
diff --git a/web/oss/src/services/tracing/api/index.ts b/web/oss/src/services/tracing/api/index.ts
index fa91ec861b..b5c61178dd 100644
--- a/web/oss/src/services/tracing/api/index.ts
+++ b/web/oss/src/services/tracing/api/index.ts
@@ -1,4 +1,7 @@
import {getBaseUrl, fetchJson, ensureProjectId, ensureAppId} from "@/oss/lib/api/assets/fetchClient"
+import {getProjectValues} from "@/oss/state/project"
+import {rangeToIntervalMinutes, tracingToGeneration} from "../lib/helpers"
+import {GenerationDashboardData, TracingDashboardData} from "../types"
export const fetchAllPreviewTraces = async (params: Record = {}, appId: string) => {
const base = getBaseUrl()
@@ -52,3 +55,69 @@ export const deletePreviewTrace = async (traceId: string) => {
return fetchJson(url, {method: "DELETE"})
}
+
+export const fetchGenerationsDashboardData = async (
+ appId: string | null | undefined,
+ _options: {
+ range: string
+ environment?: string
+ variant?: string
+ projectId?: string
+ signal?: AbortSignal
+ },
+): Promise => {
+ const {projectId: propsProjectId, signal, ...options} = _options
+ const {projectId: stateProjectId} = getProjectValues()
+
+ const base = getBaseUrl()
+ const projectId = propsProjectId || stateProjectId
+ const applicationId = ensureAppId(appId || undefined)
+
+ if (signal?.aborted) {
+ throw new DOMException("Aborted", "AbortError")
+ }
+
+ const url = new URL(`${base}/preview/tracing/spans/analytics`)
+ if (projectId) url.searchParams.set("project_id", projectId)
+ if (applicationId) url.searchParams.set("application_id", applicationId)
+
+ const conditions: any[] = []
+
+ if (applicationId) {
+ conditions.push({
+ field: "references",
+ operator: "in",
+ value: [{id: applicationId}],
+ })
+ }
+ if (options.environment) {
+ conditions.push({
+ field: "environment",
+ operator: "eq",
+ value: options.environment,
+ })
+ }
+ if (options.variant) {
+ conditions.push({
+ field: "variant",
+ operator: "eq",
+ value: options.variant,
+ })
+ }
+
+ const payload: Record = {
+ focus: "trace",
+ interval: rangeToIntervalMinutes(options.range),
+ ...(conditions.length ? {filter: {conditions}} : {}),
+ }
+
+ const response = await fetchJson(url, {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(payload),
+ signal,
+ })
+
+ const valTracing = response as TracingDashboardData
+ return tracingToGeneration(valTracing, options.range) as GenerationDashboardData
+}
diff --git a/web/oss/src/services/tracing/lib/helpers.ts b/web/oss/src/services/tracing/lib/helpers.ts
index 4cf9856eb0..8898c5b8bc 100644
--- a/web/oss/src/services/tracing/lib/helpers.ts
+++ b/web/oss/src/services/tracing/lib/helpers.ts
@@ -1,6 +1,13 @@
+import dayjs from "dayjs"
import {sortSpansByStartTime} from "@/oss/lib/helpers/tracing"
-import {TraceSpanNode, TracesResponse, SpansResponse} from "../types"
+import {
+ TraceSpanNode,
+ TracesResponse,
+ SpansResponse,
+ TracingDashboardData,
+ GenerationDashboardData,
+} from "../types"
export const isTracesResponse = (data: any): data is TracesResponse => {
return typeof data === "object" && "traces" in data
@@ -52,3 +59,74 @@ export const transformTracingResponse = (data: TraceSpanNode[]): TraceSpanNode[]
return data.map(enhance)
}
+
+export const rangeToIntervalMinutes = (range: string): number => {
+ switch (range) {
+ case "1h":
+ return 60
+ case "24h":
+ return 60
+ case "7d":
+ return 360
+ case "30d":
+ return 720
+ default:
+ return 720
+ }
+}
+
+export const normalizeDurationSeconds = (d = 0) => d / 1_000
+
+export const formatTick = (ts: number | string, range: string) =>
+ dayjs(ts).format(range === "24_hours" ? "h:mm a" : range === "7_days" ? "ddd" : "D MMM")
+
+export function tracingToGeneration(tracing: TracingDashboardData, range: string) {
+ const buckets = tracing.buckets ?? []
+
+ let successCount = 0
+ let errorCount = 0
+ let totalCost = 0
+ let totalTokens = 0
+ let totalSuccessDuration = 0
+
+ const data = buckets.map((b) => {
+ const succC = b.total?.count ?? 0
+ const errC = b.errors?.count ?? 0
+
+ const succCost = b.total?.costs ?? 0
+ const errCost = b.errors?.costs ?? 0
+
+ const succTok = b.total?.tokens ?? 0
+ const errTok = b.errors?.tokens ?? 0
+
+ const succDurS = normalizeDurationSeconds(b.total?.duration ?? 0)
+
+ successCount += succC
+ errorCount += errC
+ totalCost += succCost + errCost
+ totalTokens += succTok + errTok
+ totalSuccessDuration += succDurS
+
+ return {
+ timestamp: formatTick(b.timestamp, range),
+ success_count: succC,
+ failure_count: errC,
+ cost: succCost + errCost,
+ latency: succC ? succDurS / Math.max(succC, 1) : 0, // avg latency per success in the bucket
+ total_tokens: succTok + errTok,
+ }
+ })
+
+ const totalCount = successCount + errorCount
+
+ return {
+ data,
+ total_count: totalCount,
+ failure_rate: totalCount ? errorCount / totalCount : 0,
+ total_cost: totalCost,
+ avg_cost: totalCount ? totalCost / totalCount : 0,
+ avg_latency: successCount ? totalSuccessDuration / successCount : 0,
+ total_tokens: totalTokens,
+ avg_tokens: totalCount ? totalTokens / totalCount : 0,
+ }
+}
diff --git a/web/oss/src/services/tracing/types/index.ts b/web/oss/src/services/tracing/types/index.ts
index 01ea1a3a53..ecefd52148 100644
--- a/web/oss/src/services/tracing/types/index.ts
+++ b/web/oss/src/services/tracing/types/index.ts
@@ -119,3 +119,46 @@ export type SpansResponse = {
count: number
spans: TraceSpan[]
}
+
+export interface TracingDashboardData {
+ buckets: {
+ errors: {
+ costs: number
+ count: number
+ duration: number
+ tokens: number
+ }
+ timestamp: string
+ total: {
+ costs: number
+ count: number
+ duration: number
+ tokens: number
+ }
+ window: number
+ }[]
+ count: number
+ version: string
+}
+
+export interface GenerationDashboardData {
+ data: {
+ timestamp: number | string
+ success_count: number
+ failure_count: number
+ cost: number
+ latency: number
+ total_tokens: number
+ prompt_tokens: number
+ completion_tokens: number
+ enviornment: string
+ variant: string
+ }[]
+ total_count: number
+ failure_rate: number
+ total_cost: number
+ avg_cost: number
+ avg_latency: number
+ total_tokens: number
+ avg_tokens: number
+}
diff --git a/web/oss/src/state/newObservability/atoms/queries.ts b/web/oss/src/state/newObservability/atoms/queries.ts
index 22bc5da09b..aacdf9b83b 100644
--- a/web/oss/src/state/newObservability/atoms/queries.ts
+++ b/web/oss/src/state/newObservability/atoms/queries.ts
@@ -221,7 +221,13 @@ export const tracesQueryAtom = atomWithInfiniteQuery((get) => {
}
if (field === "status_code" && value === "STATUS_CODE_OK") {
- return {field, operator: "is_not", value: "STATUS_CODE_ERROR"}
+ if (operator === "is") {
+ return {field, operator: "is_not", value: "STATUS_CODE_ERROR"}
+ }
+
+ if (operator === "is_not") {
+ return {field, operator: "is", value: "STATUS_CODE_ERROR"}
+ }
}
if (field.includes("annotation")) {
diff --git a/web/oss/src/state/newObservability/utils/buildTraceQueryParams.ts b/web/oss/src/state/newObservability/utils/buildTraceQueryParams.ts
index 4572de81c9..ed56345e1f 100644
--- a/web/oss/src/state/newObservability/utils/buildTraceQueryParams.ts
+++ b/web/oss/src/state/newObservability/utils/buildTraceQueryParams.ts
@@ -95,7 +95,13 @@ export const buildTraceQueryParams = ({
}
if (field === "status_code" && value === "STATUS_CODE_OK") {
- return {field, operator: "is_not", value: "STATUS_CODE_ERROR"}
+ if (operator === "is") {
+ return {field, operator: "is_not", value: "STATUS_CODE_ERROR"}
+ }
+
+ if (operator === "is_not") {
+ return {field, operator: "is", value: "STATUS_CODE_ERROR"}
+ }
}
return {field, operator, value}
diff --git a/web/package.json b/web/package.json
index f5ad245cfa..282c47a75e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "agenta-web",
- "version": "0.59.3",
+ "version": "0.59.4",
"workspaces": [
"ee",
"oss",